diff --git a/.gitignore b/.gitignore index 8932af0..898fc52 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ yarn-error.log* # Misc .DS_Store *.pem +temp/ # Ignore seeding generation input data files seeding-data-input.txt diff --git a/apps/api/.env.example b/apps/api/.env.example index ce4e095..ad713fd 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -3,3 +3,4 @@ DEV_MODE=true USE_PGLITE_DATABASE_CONNECTION=false RUN_MIGRATIONS=true MODRINTH_CLIENT_SECRET=******* +PACK_GENERATION_FOLDER_OUTPUT=path/to/folder/blah \ No newline at end of file diff --git a/apps/api/db/drizzle/0005_skinny_lester.sql b/apps/api/db/drizzle/0005_skinny_lester.sql new file mode 100644 index 0000000..7ebef1e --- /dev/null +++ b/apps/api/db/drizzle/0005_skinny_lester.sql @@ -0,0 +1,18 @@ +CREATE TABLE "translation_pack" ( + "id" serial PRIMARY KEY NOT NULL, + "project_id" varchar(255) NOT NULL, + "modrinth_pack_id" varchar(255), + "last_updated" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "version_translation_pack_status" ( + "version_id" varchar(255) NOT NULL, + "language_code" varchar(10) NOT NULL, + "needs_release" boolean DEFAULT true NOT NULL, + "last_updated" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "version_translation_pack_status_version_id_language_code_pk" PRIMARY KEY("version_id","language_code") +); +--> statement-breakpoint +ALTER TABLE "translation_pack" ADD CONSTRAINT "translation_pack_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "version_translation_pack_status" ADD CONSTRAINT "version_translation_pack_status_version_id_version_id_fk" FOREIGN KEY ("version_id") REFERENCES "public"."version"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "version_translation_pack_status" ADD CONSTRAINT "version_translation_pack_status_language_code_language_code_fk" FOREIGN KEY ("language_code") REFERENCES "public"."language"("code") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/apps/api/db/drizzle/meta/0005_snapshot.json b/apps/api/db/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..f952d68 --- /dev/null +++ b/apps/api/db/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1211 @@ +{ + "id": "bac50ad7-4ea4-47a0-9493-cd5fd3d282c4", + "prevId": "28a3ae65-9f48-440a-9835-9db4a0e39a97", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.approved_user_languages": { + "name": "approved_user_languages", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "language_code": { + "name": "language_code", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "approved_user_languages_user_id_language_code_pk": { + "name": "approved_user_languages_user_id_language_code_pk", + "columns": [ + "user_id", + "language_code" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item": { + "name": "item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.language": { + "name": "language", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "native_name": { + "name": "native_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggestion_meta": { + "name": "suggestion_meta", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "language_code_unique": { + "name": "language_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "opt-in": { + "name": "opt-in", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_reports": { + "name": "project_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "reporter_id": { + "name": "reporter_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "REPORT_PRIORITY", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "status": { + "name": "status", + "type": "REPORT_STATUS", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_by_id": { + "name": "resolved_by_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "project_reports_project_id_idx": { + "name": "project_reports_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_reports_reporter_id_idx": { + "name": "project_reports_reporter_id_idx", + "columns": [ + { + "expression": "reporter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_reports_status_idx": { + "name": "project_reports_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_reports_created_at_idx": { + "name": "project_reports_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_reports_project_id_project_id_fk": { + "name": "project_reports_project_id_project_id_fk", + "tableFrom": "project_reports", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_reports_reporter_id_user_id_fk": { + "name": "project_reports_reporter_id_user_id_fk", + "tableFrom": "project_reports", + "tableTo": "user", + "columnsFrom": [ + "reporter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_reports_resolved_by_id_user_id_fk": { + "name": "project_reports_resolved_by_id_user_id_fk", + "tableFrom": "project_reports", + "tableTo": "user", + "columnsFrom": [ + "resolved_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.proposal": { + "name": "proposal", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "PROPOSAL_STATUS", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "translation_id": { + "name": "translation_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "approvals": { + "name": "approvals", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "proposal_user_id_user_id_fk": { + "name": "proposal_user_id_user_id_fk", + "tableFrom": "proposal", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proposal_translation_id_translation_id_fk": { + "name": "proposal_translation_id_translation_id_fk", + "tableFrom": "proposal", + "tableTo": "translation", + "columnsFrom": [ + "translation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.proposal_report": { + "name": "proposal_report", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "proposal_id": { + "name": "proposal_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reporter_id": { + "name": "reporter_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "REPORT_PRIORITY", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "status": { + "name": "status", + "type": "REPORT_STATUS", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_by_id": { + "name": "resolved_by_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "proposal_report_proposal_id_idx": { + "name": "proposal_report_proposal_id_idx", + "columns": [ + { + "expression": "proposal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proposal_report_reporter_id_idx": { + "name": "proposal_report_reporter_id_idx", + "columns": [ + { + "expression": "reporter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proposal_report_status_idx": { + "name": "proposal_report_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proposal_report_created_at_idx": { + "name": "proposal_report_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proposal_report_proposal_id_proposal_id_fk": { + "name": "proposal_report_proposal_id_proposal_id_fk", + "tableFrom": "proposal_report", + "tableTo": "proposal", + "columnsFrom": [ + "proposal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proposal_report_reporter_id_user_id_fk": { + "name": "proposal_report_reporter_id_user_id_fk", + "tableFrom": "proposal_report", + "tableTo": "user", + "columnsFrom": [ + "reporter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proposal_report_resolved_by_id_user_id_fk": { + "name": "proposal_report_resolved_by_id_user_id_fk", + "tableFrom": "proposal_report", + "tableTo": "user", + "columnsFrom": [ + "resolved_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.proposal_vote": { + "name": "proposal_vote", + "schema": "", + "columns": { + "proposal_id": { + "name": "proposal_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "is_upvote": { + "name": "is_upvote", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "proposal_vote_proposal_id_proposal_id_fk": { + "name": "proposal_vote_proposal_id_proposal_id_fk", + "tableFrom": "proposal_vote", + "tableTo": "proposal", + "columnsFrom": [ + "proposal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proposal_vote_user_id_user_id_fk": { + "name": "proposal_vote_user_id_user_id_fk", + "tableFrom": "proposal_vote", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "proposal_vote_proposal_id_user_id_pk": { + "name": "proposal_vote_proposal_id_user_id_pk", + "columns": [ + "proposal_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.string_reports": { + "name": "string_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "string_id": { + "name": "string_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reporter_id": { + "name": "reporter_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "REPORT_PRIORITY", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "status": { + "name": "status", + "type": "REPORT_STATUS", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_by_id": { + "name": "resolved_by_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "string_reports_string_id_idx": { + "name": "string_reports_string_id_idx", + "columns": [ + { + "expression": "string_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "string_reports_reporter_id_idx": { + "name": "string_reports_reporter_id_idx", + "columns": [ + { + "expression": "reporter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "string_reports_status_idx": { + "name": "string_reports_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "string_reports_created_at_idx": { + "name": "string_reports_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "string_reports_string_id_item_id_fk": { + "name": "string_reports_string_id_item_id_fk", + "tableFrom": "string_reports", + "tableTo": "item", + "columnsFrom": [ + "string_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "string_reports_reporter_id_user_id_fk": { + "name": "string_reports_reporter_id_user_id_fk", + "tableFrom": "string_reports", + "tableTo": "user", + "columnsFrom": [ + "reporter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "string_reports_resolved_by_id_user_id_fk": { + "name": "string_reports_resolved_by_id_user_id_fk", + "tableFrom": "string_reports", + "tableTo": "user", + "columnsFrom": [ + "resolved_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.translation": { + "name": "translation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language_code": { + "name": "language_code", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "translation_item_id_item_id_fk": { + "name": "translation_item_id_item_id_fk", + "tableFrom": "translation", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "translation_language_code_language_code_fk": { + "name": "translation_language_code_language_code_fk", + "tableFrom": "translation", + "tableTo": "language", + "columnsFrom": [ + "language_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "translation_user_id_user_id_fk": { + "name": "translation_user_id_user_id_fk", + "tableFrom": "translation", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.translation_pack": { + "name": "translation_pack", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "modrinth_pack_id": { + "name": "modrinth_pack_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "last_updated": { + "name": "last_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "translation_pack_project_id_project_id_fk": { + "name": "translation_pack_project_id_project_id_fk", + "tableFrom": "translation_pack", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "role": { + "name": "role", + "type": "USER_ROLE", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'translator'" + }, + "reputation": { + "name": "reputation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "banned": { + "name": "banned", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.version": { + "name": "version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "version_project_id_project_id_fk": { + "name": "version_project_id_project_id_fk", + "tableFrom": "version", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.version_to_item": { + "name": "version_to_item", + "schema": "", + "columns": { + "version_id": { + "name": "version_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "version_to_item_version_id_item_id_pk": { + "name": "version_to_item_version_id_item_id_pk", + "columns": [ + "version_id", + "item_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.version_translation_pack_status": { + "name": "version_translation_pack_status", + "schema": "", + "columns": { + "version_id": { + "name": "version_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "language_code": { + "name": "language_code", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "needs_release": { + "name": "needs_release", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_updated": { + "name": "last_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "version_translation_pack_status_version_id_version_id_fk": { + "name": "version_translation_pack_status_version_id_version_id_fk", + "tableFrom": "version_translation_pack_status", + "tableTo": "version", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "version_translation_pack_status_language_code_language_code_fk": { + "name": "version_translation_pack_status_language_code_language_code_fk", + "tableFrom": "version_translation_pack_status", + "tableTo": "language", + "columnsFrom": [ + "language_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "version_translation_pack_status_version_id_language_code_pk": { + "name": "version_translation_pack_status_version_id_language_code_pk", + "columns": [ + "version_id", + "language_code" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.PROPOSAL_STATUS": { + "name": "PROPOSAL_STATUS", + "schema": "public", + "values": [ + "removed", + "inaccurate", + "pending", + "accurate" + ] + }, + "public.REPORT_PRIORITY": { + "name": "REPORT_PRIORITY", + "schema": "public", + "values": [ + "low", + "medium", + "high", + "critical" + ] + }, + "public.REPORT_STATUS": { + "name": "REPORT_STATUS", + "schema": "public", + "values": [ + "open", + "investigating", + "resolved", + "invalid" + ] + }, + "public.USER_ROLE": { + "name": "USER_ROLE", + "schema": "public", + "values": [ + "translator", + "approved", + "moderator", + "admin" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/db/drizzle/meta/_journal.json b/apps/api/db/drizzle/meta/_journal.json index 8ec11c3..d2c5fcf 100644 --- a/apps/api/db/drizzle/meta/_journal.json +++ b/apps/api/db/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1745774248356, "tag": "0000_special_shaman", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1745250231824, + "tag": "0005_skinny_lester", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/db/schema/schema.ts b/apps/api/db/schema/schema.ts index a92b853..f2ea1db 100644 --- a/apps/api/db/schema/schema.ts +++ b/apps/api/db/schema/schema.ts @@ -1,4 +1,4 @@ -import {integer, jsonb, pgEnum, pgTable, primaryKey, serial, text, timestamp, varchar, index} from "drizzle-orm/pg-core"; +import {integer, jsonb, pgEnum, pgTable, primaryKey, serial, text, timestamp, varchar, index, boolean} from "drizzle-orm/pg-core"; import {relations} from "drizzle-orm"; @@ -26,6 +26,15 @@ export const project = pgTable("project", { optIn: timestamp("opt-in", {withTimezone: true}), // When the project opted in }); +// Translation pack for a project +export const translationPack = pgTable("translation_pack", { + id: serial("id").notNull().primaryKey(), // Generic serial id + projectId: varchar("project_id", {length: 255}).notNull() // -> Project id + .references(() => project.id, {onDelete: 'cascade'}), + modrinthPackId: varchar("modrinth_pack_id", {length: 255}), // Modrinth resource pack project id + lastUpdated: timestamp("last_updated", {withTimezone: true}).notNull().defaultNow(), +}); + // A version of a project export const version = pgTable("version", { id: varchar("id", {length: 255}).notNull().primaryKey(), // Modrinth version id @@ -39,6 +48,16 @@ export const versionToItem = pgTable('version_to_item', { itemId: integer("item_id").notNull(), // -> Items id }, (t) => [primaryKey({columns: [t.versionId, t.itemId]})]); +// Track versions that need translation pack updates +export const versionTranslationPackStatus = pgTable("version_translation_pack_status", { + versionId: varchar("version_id", {length: 255}).notNull() // -> Version id + .references(() => version.id, {onDelete: 'cascade'}), + languageCode: varchar("language_code", {length: 10}).notNull() // -> Language code + .references(() => language.code, {onDelete: 'cascade'}), + needsRelease: boolean("needs_release").notNull().default(true), // If true, this version needs a translation pack release + lastUpdated: timestamp("last_updated", {withTimezone: true}).notNull().defaultNow(), +}, (t) => [primaryKey({columns: [t.versionId, t.languageCode]})]); + // The base of the translations. Storing the translation key and the default value from en_us. export const item = pgTable("item", { id: serial("id").notNull().primaryKey(), // Generic serial id @@ -334,10 +353,25 @@ export const projectReportsRelations = relations(projectReports, ({ one }) => ({ }), })); -/* -Examples from: -- https://github.com/DaFuqs/Spectrum/blob/1.20.1-aria-for-painters/src/main/resources/assets/spectrum/lang - */ +// TranslationPack relations +export const translationPackRelations = relations(translationPack, ({ one }) => ({ + project: one(project, { + fields: [translationPack.projectId], + references: [project.id], + }), +})); + +// VersionTranslationPackStatus relations +export const versionTranslationPackStatusRelations = relations(versionTranslationPackStatus, ({ one }) => ({ + version: one(version, { + fields: [versionTranslationPackStatus.versionId], + references: [version.id], + }), + language: one(language, { + fields: [versionTranslationPackStatus.languageCode], + references: [language.code], + }), +})); export const schema = { user, @@ -353,6 +387,8 @@ export const schema = { proposalReport, stringReports, projectReports, + translationPack, + versionTranslationPackStatus, userRelations, projectRelations, versionRelations, @@ -366,4 +402,6 @@ export const schema = { approvedUserLanguagesRelations, stringReportsRelations, projectReportsRelations, + translationPackRelations, + versionTranslationPackStatusRelations, } diff --git a/apps/api/package.json b/apps/api/package.json index 6c0a739..5e56bff 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -8,7 +8,7 @@ "build": "tsc -p tsconfig.json", "start": "tsx ./dist/server.js", "docker:start": "tsx ./dist/server.js", - "dev": "tsx watch server.ts --clear-screen=false", + "dev": "tsx server.ts --clear-screen=false", "db:gen": "drizzle-kit generate", "db:push": "drizzle-kit push --config=drizzle-dev.config.ts", "db:migrate": "drizzle-kit migrate --config=drizzle-dev.config.ts", @@ -30,6 +30,7 @@ "dotenv": "^16.5.0", "drizzle-orm": "^0.43.1", "fastify": "^5.3.3", + "jszip": "^3.10.1", "pg": "^8.16.0", "typerinth": "^1.1.1", "typescript": "^5.8.3", diff --git a/apps/api/routes/index.ts b/apps/api/routes/index.ts index 671565e..5ff695a 100644 --- a/apps/api/routes/index.ts +++ b/apps/api/routes/index.ts @@ -47,6 +47,7 @@ import V1_ProjectReportsResolve from "./v1/projects/reports/resolve"; // Tasks import V1_TasksList from "./v1/tasks/list"; import V1_TasksGet from "./v1/tasks/get"; +import translationPacks from "./v1/moderation/translation-packs"; interface RouteStorage { [apiVersion: string]: { @@ -100,6 +101,7 @@ const routes: RouteStorage = { // Tasks tasks_list: V1_TasksList, tasks_get: V1_TasksGet, + test_upload: translationPacks, }, }; diff --git a/apps/api/routes/v1/moderation/translation-packs.ts b/apps/api/routes/v1/moderation/translation-packs.ts new file mode 100644 index 0000000..76f5845 --- /dev/null +++ b/apps/api/routes/v1/moderation/translation-packs.ts @@ -0,0 +1,74 @@ +import APIRoute from "../../route"; +import { processTranslationPacksManually } from "../../../util/jobs/translation-packs"; +import { AuthUtils } from "../../../util/auth-utils"; + +export default { + type: "GET", + route: "/moderation/translation-packs/process", + schema: { + description: + "Manually trigger translation pack generation for all projects with pending translations", + security: [{ modrinthToken: [] }], + tags: ["moderation"], + response: { + 202: { + type: "object", + properties: { + taskId: { type: "string", description: "ID of the background task" }, + message: { type: "string", description: "Success message" }, + }, + }, + 401: { + type: "object", + properties: { + message: { + type: "string", + description: "Authentication error message", + }, + }, + }, + 403: { + type: "object", + properties: { + message: { + type: "string", + description: "Authorization error message", + }, + }, + }, + }, + }, + func: async (request, response) => { + const authUser = await AuthUtils.authenticateUser(request, response); + + if (!authUser) { + response.status(401).send({ + message: "You must be logged in to access this endpoint", + }); + return; + } + + if (authUser.role !== "admin") { + response.status(403).send({ + message: "You do not have permission to access this endpoint", + }); + return; + } + + try { + // Run the translation pack generation process in the background + const taskId = await processTranslationPacksManually(); + + response.status(202).send({ + taskId, + message: "Translation pack generation started successfully", + }); + } catch (error) { + console.error("Error starting translation pack generation:", error); + response.status(500).send({ + message: + "An error occurred while starting the translation pack generation", + }); + } + }, +} as APIRoute; diff --git a/apps/api/util/auth-utils.ts b/apps/api/util/auth-utils.ts index 498ca4a..ae95a62 100644 --- a/apps/api/util/auth-utils.ts +++ b/apps/api/util/auth-utils.ts @@ -11,7 +11,7 @@ const ROLE_HIERARCHY = ["translator", "approved", "moderator", "admin"]; export interface AuthUser { id: string; - role: string; + role: "translator" | "approved" | "moderator" | "admin"; isBanned: boolean; reputation: number; modrinthUser?: User; diff --git a/apps/api/util/jobs/index.ts b/apps/api/util/jobs/index.ts index 40f9c79..77a0c7b 100644 --- a/apps/api/util/jobs/index.ts +++ b/apps/api/util/jobs/index.ts @@ -1,5 +1,6 @@ import { CronJob } from "cron"; import { checkProjectsForNewVersions, checkProjectsValid } from "./projects"; +import { processTranslationPacks } from "./translation-packs"; import { updateLanguages } from "./language"; export async function setupJobs() { @@ -18,6 +19,10 @@ export async function setupJobs() { new CronJob("0 6 */2 * *", () => { checkProjectsForNewVersions(); }), + // Every Wednesday at midnight UTC (for translation pack generation) + new CronJob("0 0 * * 3", () => { + processTranslationPacks(); + }), ]; jobs.map((job) => job.start()); diff --git a/apps/api/util/jobs/translation-packs.ts b/apps/api/util/jobs/translation-packs.ts new file mode 100644 index 0000000..3ba51e1 --- /dev/null +++ b/apps/api/util/jobs/translation-packs.ts @@ -0,0 +1,679 @@ +import { and, eq, gt, inArray, sql, count } from "drizzle-orm"; +import db from "../../db"; +import { + item, + language, + proposal, + project, + translation, + translationPack, + versionTranslationPackStatus, + versionToItem, +} from "../../db/schema/schema"; +import taskManager from "../task-manager"; +import fs from "fs/promises"; +import path from "path"; +import JSZip from "jszip"; +import crypto from "crypto"; + +// Directory to store generated resource packs +const RESOURCE_PACK_DIR = process.env.PACK_GENERATION_FOLDER_OUTPUT!; + +/** + * Checks all versions with dirty translation status and generates + * translation packs for those meeting the threshold requirements. + * + * Threshold: + * - At least 10% translated OR + * - At least 45 strings translated + */ +export async function processTranslationPacks() { + const taskId = taskManager.createTask({ + description: "Processing translation packs", + }); + + // Run the task in the background - don't await the result + taskManager.executeTask(taskId, async (updateProgress) => { + updateProgress = (progress: number) => { + console.log("processing progress: " + progress); + }; + + // 0. Initialize translation pack statuses for versions without any + await initializeMissingTranslationPackStatuses(); + updateProgress(5); + + // 1. Get all versions that need translation pack updates + const dirtyStatuses = await db.query.versionTranslationPackStatus.findMany({ + where: eq(versionTranslationPackStatus.needsRelease, true), + with: { + version: { + with: { + project: true, + }, + }, + }, + }); + + if (dirtyStatuses.length === 0) { + updateProgress(100); + return { processed: 0, message: "No translation packs to update" }; + } + + updateProgress(10); + + // Group by project for more efficient processing + const versionsByProject: Record< + string, + { + projectId: string; + versions: { + versionId: string; + languageCode: string; + }[]; + } + > = {}; + + dirtyStatuses.forEach((status) => { + const projectId = status.version.projectId; + + if (!versionsByProject[projectId]) { + versionsByProject[projectId] = { + projectId, + versions: [], + }; + } + + versionsByProject[projectId].versions.push({ + versionId: status.versionId, + languageCode: status.languageCode, + }); + }); + + updateProgress(10); + + // Process each project + const results: any[] = []; + let processedCount = 0; + const projectIds = Object.keys(versionsByProject); + + for (let i = 0; i < projectIds.length; i++) { + const projectId = projectIds[i]; + const projectData = versionsByProject[projectId]; + + try { + // Process this project's versions + const result = await processProjectTranslationPacks( + projectData.projectId, + projectData.versions, + ); + + results.push(result); + } catch (error) { + console.error( + `Error processing translation packs for project ${projectId}:`, + error, + ); + results.push({ + projectId, + success: false, + error: String(error), + }); + } + + processedCount++; + const progress = + Math.floor((processedCount / projectIds.length) * 90) + 10; + updateProgress(progress); + } + + updateProgress(100); + + return { + processed: results.length, + results, + }; + }); + + return taskId; +} + +/** + * Find versions without any translation pack status records and create initial statuses for them + * @returns Number of status records created + */ +async function initializeMissingTranslationPackStatuses(): Promise { + // 1. Get all versions + const allVersions = await db.query.version.findMany({ + with: { + project: true, + }, + }); + + if (allVersions.length === 0) { + console.log("No versions found in the database"); + return 0; + } + + let statusesCreated = 0; + + // 2. Check each version + for (const version of allVersions) { + // Get available languages with translations for this version + const versionItems = await db.query.versionToItem.findMany({ + where: eq(versionToItem.versionId, version.id), + columns: { itemId: true }, + }); + + const itemIds = versionItems.map((v) => v.itemId); + + if (itemIds.length === 0) { + continue; // Skip versions with no items + } + + // Find languages with accurate proposals for these items + const availableLanguages = await db + .selectDistinct({ languageCode: translation.languageCode }) + .from(translation) + .where( + and( + inArray(translation.itemId, itemIds), + sql`exists ( + select 1 from ${proposal} + where ${proposal.translationId} = ${translation.id} + and ${proposal.status} = 'accurate' + )`, + ), + ); + + if (availableLanguages.length === 0) { + continue; // No languages with translations available + } + + // Get existing status records for this version + const existingStatuses = + await db.query.versionTranslationPackStatus.findMany({ + where: eq(versionTranslationPackStatus.versionId, version.id), + }); + + const existingLanguageCodes = existingStatuses.map((s) => s.languageCode); + + // Create status records for languages that don't have them yet + for (const { languageCode } of availableLanguages) { + if (!existingLanguageCodes.includes(languageCode)) { + try { + await db.insert(versionTranslationPackStatus).values({ + versionId: version.id, + languageCode, + needsRelease: true, + lastUpdated: new Date(), + }); + statusesCreated++; + } catch (error) { + console.error( + `Error creating translation pack status for version ${version.id}, language ${languageCode}:`, + error, + ); + } + } + } + } + + console.log(`Created ${statusesCreated} new translation pack status records`); + return statusesCreated; +} + +/** + * Process translation packs for a specific project + */ +async function processProjectTranslationPacks( + projectId: string, + versionLangs: { versionId: string; languageCode: string }[], +): Promise { + // Check if a translation pack project exists for this project + let packProject = await db.query.translationPack.findFirst({ + where: eq(translationPack.projectId, projectId), + }); + + // Define two possible result types - one for single versions and one for grouped versions + type SingleVersionResult = { + versionId: string; + languageCode: string; + meetsThreshold: boolean; + translatedCount?: number; + totalCount?: number; + translatedPercentage?: number; + packCreated?: boolean; + error?: string; + }; + + type GroupedVersionsResult = { + versionIds: string[]; + languageCodes: string[]; + generatedPack?: boolean; + packPath?: string | null; + error?: string; + }; + + // Union type that can be either single version or grouped versions result + type TranslationPackResult = SingleVersionResult | GroupedVersionsResult; + + const results: TranslationPackResult[] = []; + + // Group versions by their translations to avoid regenerating identical packs + const versionsByTranslations: Record< + string, + { + versionIds: string[]; + languageCodes: string[]; + translationFiles?: Record>; + } + > = {}; + + // First pass: gather all translations and check which versions meet the threshold + for (const { versionId, languageCode } of versionLangs) { + try { + // 1. Check if threshold is met + const versionItems = await db.query.versionToItem.findMany({ + where: eq(versionToItem.versionId, versionId), + columns: { itemId: true }, + }); + + const itemIds = versionItems.map((v) => v.itemId); + const totalStrings = itemIds.length; + + if (totalStrings === 0) { + results.push({ + versionId, + languageCode, + meetsThreshold: true, + translatedCount: 0, + totalCount: 0, + translatedPercentage: 0, + error: "No strings found for this version", + }); + continue; + } + + // Count translations with "accurate" proposals + const translatedCountQuery = await db + .select({ + count: count(), + }) + .from(translation) + .where( + and( + inArray(translation.itemId, itemIds), + eq(translation.languageCode, languageCode), + // Check if an accurate proposal exists for this translation + sql`exists ( + select 1 from ${proposal} + where ${proposal.translationId} = ${translation.id} + and ${proposal.status} = 'accurate' + )`, + ), + ); + + const translatedCount = Number(translatedCountQuery[0]?.count || 0); + const translatedPercentage = (translatedCount / totalStrings) * 100; + /** + * const meetsThreshold = + translatedCount >= 45 || translatedPercentage >= 10; + */ + const meetsThreshold = true; + + results.push({ + versionId, + languageCode, + meetsThreshold, + translatedCount, + totalCount: totalStrings, + translatedPercentage, + }); + + console.log(results); + + // If threshold is not met, skip this version + if (!meetsThreshold) { + continue; + } + + // Generate translation files for this version + const translationFiles = + await generateTranslationFilesForVersion(versionId); + + // Create a hash of the translation files to identify identical packs + const translationsHash = hashTranslations(translationFiles); + + if (!versionsByTranslations[translationsHash]) { + versionsByTranslations[translationsHash] = { + versionIds: [], + languageCodes: [], + translationFiles, + }; + } + + if ( + !versionsByTranslations[translationsHash].versionIds.includes(versionId) + ) { + versionsByTranslations[translationsHash].versionIds.push(versionId); + } + + if ( + !versionsByTranslations[translationsHash].languageCodes.includes( + languageCode, + ) + ) { + versionsByTranslations[translationsHash].languageCodes.push( + languageCode, + ); + } + + // Mark as released (not dirty anymore) + await db + .update(versionTranslationPackStatus) + .set({ needsRelease: false }) + .where( + and( + eq(versionTranslationPackStatus.versionId, versionId), + eq(versionTranslationPackStatus.languageCode, languageCode), + ), + ); + + // If there's no pack project yet and this is the first version meeting threshold, + // create one + if (!packProject) { + // Create new translation pack project + const [newPack] = await db + .insert(translationPack) + .values({ + projectId, + lastUpdated: new Date(), + }) + .returning(); + + packProject = newPack; + } + } catch (error) { + console.error( + `Error processing translation pack for version ${versionId}, language ${languageCode}:`, + error, + ); + results.push({ + versionId, + languageCode, + meetsThreshold: false, + error: String(error), + }); + } + } + + // Second pass: generate actual resource packs for each unique translation set + for (const [translationsHash, data] of Object.entries( + versionsByTranslations, + )) { + if ( + !data.translationFiles || + Object.keys(data.translationFiles).length === 0 + ) { + continue; + } + + try { + // Generate the pack and get its path + const packPath = await generateResourcePack( + projectId, + data.versionIds, + data.translationFiles, + ); + + if (packPath) { + console.log( + `Generated resource pack for project ${projectId} with ${data.versionIds.length} version(s) at ${packPath}`, + ); + + results.push({ + versionIds: data.versionIds, + languageCodes: data.languageCodes, + generatedPack: true, + packPath, + }); + + // Update pack project with latest update time + if (packProject) { + await db + .update(translationPack) + .set({ lastUpdated: new Date() }) + .where(eq(translationPack.id, packProject.id)); + } + } else { + throw new Error("Failed to generate resource pack"); + } + } catch (error) { + console.error( + `Error generating resource pack for project ${projectId}:`, + error, + ); + results.push({ + versionIds: data.versionIds, + languageCodes: data.languageCodes, + generatedPack: false, + error: String(error), + }); + } + } + + return { + projectId, + packId: packProject?.id, + packCount: Object.keys(versionsByTranslations).length, + results, + }; +} + +/** + * Generate a hash of translation files to identify identical packs + */ +export function hashTranslations( + translationFiles: Record>, +): string { + const content = JSON.stringify(translationFiles); + return crypto.createHash("md5").update(content).digest("hex"); +} + +/** + * Generate a resource pack file for the provided translations and save it to disk + * + * @returns Path to the generated resource pack file + */ +export async function generateResourcePack( + projectId: string, + versionIds: string[], + translationFiles: Record>, +): Promise { + try { + // Get project info + let projectInfo; + try { + projectInfo = await ( + await fetch("https://api.modrinth.com/v2/project/" + projectId) + ).json(); + } catch (error) { + // For testing purposes, create a mock project info if database query fails + console.log("Using mock project info for testing purposes"); + projectInfo = { + id: projectId, + name: "Test Project", + slug: projectId, + }; + } + + if (!projectInfo) { + // For testing purposes, create a mock project info if project not found + console.log("Using mock project info for testing purposes"); + projectInfo = { + id: projectId, + name: "Test Project", + slug: projectId, + }; + } + + // Ensure temp directory exists + await fs.mkdir(RESOURCE_PACK_DIR, { recursive: true }); + + // Create a unique filename for this pack - include version hash to identify identical packs + const translationsHash = hashTranslations(translationFiles); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const packName = `${projectInfo.slug || projectId}_${translationsHash}_${timestamp}.zip`; + const packPath = path.join(RESOURCE_PACK_DIR, packName); + + // Check if an identical pack (same hash) already exists for better efficiency + try { + const files = await fs.readdir(RESOURCE_PACK_DIR); + const existingPack = files.find((file) => + file.includes(`${projectInfo.slug || projectId}_${translationsHash}_`), + ); + + if (existingPack) { + console.log( + `Using existing pack with same content hash: ${existingPack}`, + ); + return path.join(RESOURCE_PACK_DIR, existingPack); + } + } catch (err) { + // Directory might not exist yet, continue with pack creation + } + + // Create the zip file + const zip = new JSZip(); + + // Add pack.mcmeta file + const packMcmeta = { + pack: { + pack_format: 9, // Compatible with most recent versions + description: `Translations for ${projectInfo.name || projectId} provided by Loqui`, + }, + }; + zip.file("pack.mcmeta", JSON.stringify(packMcmeta, null, 2)); + + // Add pack.png if we had one (we don't right now) + // zip.file('pack.png', await fs.readFile('path/to/pack/icon.png')); + + // Process all translation files + // Organize by namespaces for better file organization + const namespaceMap: Record< + string, + Record> + > = {}; + + // Organize translations by namespace + for (const [langCode, translations] of Object.entries(translationFiles)) { + for (const [fullKey, value] of Object.entries(translations)) { + // Split the key into namespace and key parts + // Format: "namespace:key" -> ["namespace", "key"] + const keyParts = fullKey.split(":"); + let namespace: string, key: string; + + if (keyParts.length > 1) { + namespace = keyParts[0]; + key = keyParts.slice(1).join(":"); // Handle cases with multiple colons + } else { + // Default namespace if no colon in key + namespace = "minecraft"; + key = fullKey; + } + + // Initialize namespace and language objects if needed + if (!namespaceMap[namespace]) { + namespaceMap[namespace] = {}; + } + + if (!namespaceMap[namespace][langCode]) { + namespaceMap[namespace][langCode] = {}; + } + + // Add the translation + namespaceMap[namespace][langCode][key] = value; + } + } + + // Add language files to the zip in the correct directory structure + for (const [namespace, languages] of Object.entries(namespaceMap)) { + for (const [langCode, translations] of Object.entries(languages)) { + const filePath = `assets/${namespace}/lang/${langCode}.json`; + zip.file(filePath, JSON.stringify(translations, null, 2)); + } + } + + // Generate the ZIP file + const content = await zip.generateAsync({ type: "nodebuffer" }); + await fs.writeFile(packPath, content); + + console.log(`Resource pack generated at ${packPath}`); + return packPath; + } catch (error) { + console.error("Error generating resource pack:", error); + return null; + } +} + +/** + * Generate the translation files data structure for a specific version + * in the format required by Minecraft resource packs: + * { "lang-code": { "key": "translation value", ... }, ... } + */ +export async function generateTranslationFilesForVersion( + versionId: string, +): Promise>> { + // 1. Get all items for this version + const versionItems = await db.query.versionToItem.findMany({ + where: eq(versionToItem.versionId, versionId), + with: { + item: true, + }, + }); + + const itemIds = versionItems.map((v) => v.item.id); + + // 2. Get all translations with accurate proposals for these items + const translations = await db.query.translation.findMany({ + where: inArray(translation.itemId, itemIds), + with: { + proposals: { + where: eq(proposal.status, "accurate"), + }, + item: true, + }, + }); + + // 3. Organize translations by language code + const langMap: Record> = {}; + + for (const trans of translations) { + // Skip if no accurate proposal + if (trans.proposals.length === 0) continue; + + // Use the first accurate proposal (there should only be one) + const accurateProposal = trans.proposals[0]; + + if (!langMap[trans.languageCode]) { + langMap[trans.languageCode] = {}; + } + + langMap[trans.languageCode][trans.item.key] = accurateProposal.value; + } + + return langMap; +} + +/** + * Update translation packs manually from admin/moderator action + */ +export async function processTranslationPacksManually() { + return processTranslationPacks(); +} + +export function createModrinthResourcePack( + projectId: string, +): string | PromiseLike | null { + return null; +} diff --git a/apps/api/util/proposal-utils.ts b/apps/api/util/proposal-utils.ts index 1cb0b70..4c03063 100644 --- a/apps/api/util/proposal-utils.ts +++ b/apps/api/util/proposal-utils.ts @@ -1,6 +1,11 @@ import db from "../db"; -import { proposal } from "../db/schema/schema"; -import { and, eq, not, sql } from "drizzle-orm"; +import { + proposal, + translation, + versionToItem, + versionTranslationPackStatus, +} from "../db/schema/schema"; +import { and, eq, inArray, not, sql } from "drizzle-orm"; /** * Updates proposal statuses based on their votes and relative ranking within a translation @@ -43,6 +48,7 @@ export async function updateProposalStatuses( score: p.score, approvals: p.approvals, rank: p.score + p.approvals * 4, + status: p.status, // Keep track of original status })); // Sort by rank (highest first) @@ -50,6 +56,7 @@ export async function updateProposalStatuses( // Find the highest ranked proposal const topProposal = rankedProposals[0]; + let newAccurateProposalIds: number[] = []; // Special case: if there's only one proposal if (rankedProposals.length === 1) { @@ -61,6 +68,14 @@ export async function updateProposalStatuses( .set({ status: newStatus }) .where(eq(proposal.id, topProposal.id)); + // If status changed to accurate, track it + if (newStatus === "accurate" && topProposal.status !== "accurate") { + newAccurateProposalIds.push(topProposal.id); + } + + if (newAccurateProposalIds.length > 0) { + await markVersionsAsDirty(translationId, newAccurateProposalIds); + } return; } @@ -75,9 +90,79 @@ export async function updateProposalStatuses( .update(proposal) .set({ status: newStatus }) .where(eq(proposal.id, p.id)); + + // If status changed to accurate, track it + if (newStatus === "accurate" && p.status !== "accurate") { + newAccurateProposalIds.push(p.id); + } + } + + // If any proposals became accurate, mark relevant versions as dirty + if (newAccurateProposalIds.length > 0) { + await markVersionsAsDirty(translationId, newAccurateProposalIds); } } catch (error) { console.error("Error updating proposal statuses:", error); throw error; } } + +/** + * Marks all versions that contain an item as needing translation pack updates + * @param translationId The translation ID that was updated + * @param proposalIds The proposal IDs that became accurate + */ +async function markVersionsAsDirty( + translationId: number, + proposalIds: number[], +): Promise { + try { + // 1. Get the translation to find item and language + const translationData = await db.query.translation.findFirst({ + where: (t) => eq(t.id, translationId), + columns: { + itemId: true, + languageCode: true, + }, + }); + + if (!translationData) { + console.error(`Translation not found for ID: ${translationId}`); + return; + } + + // 2. Find all versions that contain this item + const versionLinks = await db.query.versionToItem.findMany({ + where: (vti) => eq(vti.itemId, translationData.itemId), + columns: { + versionId: true, + }, + }); + + const versionIds = versionLinks.map((v) => v.versionId); + + // 3. For each version, mark it as needing a release for this language + for (const versionId of versionIds) { + await db + .insert(versionTranslationPackStatus) + .values({ + versionId, + languageCode: translationData.languageCode, + needsRelease: true, + lastUpdated: new Date(), + }) + .onConflictDoUpdate({ + target: [ + versionTranslationPackStatus.versionId, + versionTranslationPackStatus.languageCode, + ], + set: { + needsRelease: true, + lastUpdated: new Date(), + }, + }); + } + } catch (error) { + console.error("Error marking versions as dirty:", error); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d7bbfd..82cd7d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: fastify: specifier: ^5.3.3 version: 5.3.3 + jszip: + specifier: ^3.10.1 + version: 3.10.1 pg: specifier: ^8.16.0 version: 8.16.0 @@ -2264,6 +2267,9 @@ packages: next: '>=15.0.0' react: '>= 16.8.0' + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cosmiconfig-typescript-loader@6.1.0: resolution: {integrity: sha512-tJ1w35ZRUiM5FeTzT7DtYWAFFv37ZLqSRkGi2oeCK1gPhvaWjkAtfXvLmvE1pRfxxp9aQo6ba/Pvg1dKj05D4g==} engines: {node: '>=v18'} @@ -2956,6 +2962,9 @@ packages: resolution: {integrity: sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -3110,6 +3119,9 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -3176,6 +3188,9 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3183,6 +3198,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + light-my-request@6.6.0: resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} @@ -3432,6 +3450,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3623,6 +3644,9 @@ packages: engines: {node: '>=14'} hasBin: true + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process-warning@4.0.1: resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} @@ -3732,6 +3756,9 @@ packages: read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -3811,6 +3838,9 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -3859,6 +3889,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -3963,6 +3996,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -6097,6 +6133,8 @@ snapshots: next: 15.3.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 + core-util-is@1.0.3: {} + cosmiconfig-typescript-loader@6.1.0(@types/node@22.15.21)(cosmiconfig@9.0.0(typescript@5.8.3))(typescript@5.8.3): dependencies: '@types/node': 22.15.21 @@ -6882,6 +6920,8 @@ snapshots: ignore@7.0.4: {} + immediate@3.0.6: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -7032,6 +7072,8 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + isarray@1.0.0: {} + isarray@2.0.5: {} isexe@2.0.0: {} @@ -7099,6 +7141,13 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -7108,6 +7157,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + light-my-request@6.6.0: dependencies: cookie: 1.0.2 @@ -7334,6 +7387,8 @@ snapshots: package-json-from-dist@1.0.1: {} + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -7509,6 +7564,8 @@ snapshots: prettier@3.5.3: {} + process-nextick-args@2.0.1: {} + process-warning@4.0.1: {} process-warning@5.0.0: {} @@ -7606,6 +7663,16 @@ snapshots: dependencies: pify: 2.3.0 + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -7697,6 +7764,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} safe-push-apply@1.0.0: @@ -7748,6 +7817,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + setimmediate@1.0.5: {} + setprototypeof@1.2.0: {} sharp@0.34.1: @@ -7904,6 +7975,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1