diff --git a/api/drizzle/0002_curved_eternals.sql b/api/drizzle/0002_curved_eternals.sql new file mode 100644 index 0000000..cf430fd --- /dev/null +++ b/api/drizzle/0002_curved_eternals.sql @@ -0,0 +1 @@ +ALTER TABLE "words" ADD COLUMN "ref" varchar(20) DEFAULT '' NOT NULL; \ No newline at end of file diff --git a/api/drizzle/0003_lame_rachel_grey.sql b/api/drizzle/0003_lame_rachel_grey.sql new file mode 100644 index 0000000..3c6c501 --- /dev/null +++ b/api/drizzle/0003_lame_rachel_grey.sql @@ -0,0 +1,12 @@ +CREATE TABLE "word_reviews" ( + "pk" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "word_reviews_pk_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "word_id" integer NOT NULL, + "user_id" integer NOT NULL, + "correct" boolean NOT NULL +); +--> statement-breakpoint +DROP INDEX "idx_word_correct";--> statement-breakpoint +ALTER TABLE "word_reviews" ADD CONSTRAINT "word_reviews_word_id_words_id_fk" FOREIGN KEY ("word_id") REFERENCES "public"."words"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "word_reviews" ADD CONSTRAINT "word_reviews_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "idx_unique_word_review" ON "word_reviews" USING btree ("word_id","user_id");--> statement-breakpoint +ALTER TABLE "words" DROP COLUMN "correct"; \ No newline at end of file diff --git a/api/drizzle/0004_oval_leopardon.sql b/api/drizzle/0004_oval_leopardon.sql new file mode 100644 index 0000000..111077b --- /dev/null +++ b/api/drizzle/0004_oval_leopardon.sql @@ -0,0 +1 @@ +ALTER TABLE "models" ADD COLUMN "retries" integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/api/drizzle/meta/0002_snapshot.json b/api/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..b9ad76e --- /dev/null +++ b/api/drizzle/meta/0002_snapshot.json @@ -0,0 +1,484 @@ +{ + "id": "e5753a3a-46b2-4bc7-acf0-2f5ce83107a4", + "prevId": "26889a4e-c30f-4c33-a2df-4f9bf0cc201f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.batches": { + "name": "batches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "ietf_code": { + "name": "ietf_code", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pending": { + "name": "pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_unique_batch": { + "name": "idx_unique_batch", + "columns": [ + { + "expression": "ietf_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_batch_user_id": { + "name": "idx_batch_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "batches_user_id_users_id_fk": { + "name": "batches_user_id_users_id_fk", + "tableFrom": "batches", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.models": { + "name": "models", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "models_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "word_id": { + "name": "word_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_unique_model": { + "name": "idx_unique_model", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "word_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_word_id": { + "name": "idx_model_word_id", + "columns": [ + { + "expression": "word_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "models_word_id_words_id_fk": { + "name": "models_word_id_words_id_fk", + "tableFrom": "models", + "tableTo": "words", + "columnsFrom": [ + "word_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "users_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "wacs_user_id": { + "name": "wacs_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_unique_user": { + "name": "idx_unique_user", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.words": { + "name": "words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "words_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "batch_id": { + "name": "batch_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "correct": { + "name": "correct", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ref": { + "name": "ref", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_unique_word": { + "name": "idx_unique_word", + "columns": [ + { + "expression": "word", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_word_batch_id": { + "name": "idx_word_batch_id", + "columns": [ + { + "expression": "batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_word_correct": { + "name": "idx_word_correct", + "columns": [ + { + "expression": "correct", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "words_batch_id_batches_id_fk": { + "name": "words_batch_id_batches_id_fk", + "tableFrom": "words", + "tableTo": "batches", + "columnsFrom": [ + "batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/api/drizzle/meta/0003_snapshot.json b/api/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..0ef9046 --- /dev/null +++ b/api/drizzle/meta/0003_snapshot.json @@ -0,0 +1,560 @@ +{ + "id": "baf2bf68-def5-491e-a8e5-5fd6e4e9c4d0", + "prevId": "e5753a3a-46b2-4bc7-acf0-2f5ce83107a4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.batches": { + "name": "batches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "ietf_code": { + "name": "ietf_code", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pending": { + "name": "pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_unique_batch": { + "name": "idx_unique_batch", + "columns": [ + { + "expression": "ietf_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_batch_user_id": { + "name": "idx_batch_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "batches_user_id_users_id_fk": { + "name": "batches_user_id_users_id_fk", + "tableFrom": "batches", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.models": { + "name": "models", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "models_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "word_id": { + "name": "word_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_unique_model": { + "name": "idx_unique_model", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "word_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_word_id": { + "name": "idx_model_word_id", + "columns": [ + { + "expression": "word_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "models_word_id_words_id_fk": { + "name": "models_word_id_words_id_fk", + "tableFrom": "models", + "tableTo": "words", + "columnsFrom": [ + "word_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "users_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "wacs_user_id": { + "name": "wacs_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_unique_user": { + "name": "idx_unique_user", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.word_reviews": { + "name": "word_reviews", + "schema": "", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "word_reviews_pk_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "word_id": { + "name": "word_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "correct": { + "name": "correct", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_unique_word_review": { + "name": "idx_unique_word_review", + "columns": [ + { + "expression": "word_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "word_reviews_word_id_words_id_fk": { + "name": "word_reviews_word_id_words_id_fk", + "tableFrom": "word_reviews", + "tableTo": "words", + "columnsFrom": [ + "word_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "word_reviews_user_id_users_id_fk": { + "name": "word_reviews_user_id_users_id_fk", + "tableFrom": "word_reviews", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.words": { + "name": "words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "words_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "batch_id": { + "name": "batch_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_unique_word": { + "name": "idx_unique_word", + "columns": [ + { + "expression": "word", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_word_batch_id": { + "name": "idx_word_batch_id", + "columns": [ + { + "expression": "batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "words_batch_id_batches_id_fk": { + "name": "words_batch_id_batches_id_fk", + "tableFrom": "words", + "tableTo": "batches", + "columnsFrom": [ + "batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/api/drizzle/meta/0004_snapshot.json b/api/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..63a24b2 --- /dev/null +++ b/api/drizzle/meta/0004_snapshot.json @@ -0,0 +1,567 @@ +{ + "id": "b2390d4e-cca5-4ef1-b87a-c4ccf13f58e0", + "prevId": "baf2bf68-def5-491e-a8e5-5fd6e4e9c4d0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.batches": { + "name": "batches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "ietf_code": { + "name": "ietf_code", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pending": { + "name": "pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_unique_batch": { + "name": "idx_unique_batch", + "columns": [ + { + "expression": "ietf_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_batch_user_id": { + "name": "idx_batch_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "batches_user_id_users_id_fk": { + "name": "batches_user_id_users_id_fk", + "tableFrom": "batches", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.models": { + "name": "models", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "models_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "word_id": { + "name": "word_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_unique_model": { + "name": "idx_unique_model", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "word_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_word_id": { + "name": "idx_model_word_id", + "columns": [ + { + "expression": "word_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "models_word_id_words_id_fk": { + "name": "models_word_id_words_id_fk", + "tableFrom": "models", + "tableTo": "words", + "columnsFrom": [ + "word_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "users_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "wacs_user_id": { + "name": "wacs_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_unique_user": { + "name": "idx_unique_user", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.word_reviews": { + "name": "word_reviews", + "schema": "", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "word_reviews_pk_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "word_id": { + "name": "word_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "correct": { + "name": "correct", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_unique_word_review": { + "name": "idx_unique_word_review", + "columns": [ + { + "expression": "word_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "word_reviews_word_id_words_id_fk": { + "name": "word_reviews_word_id_words_id_fk", + "tableFrom": "word_reviews", + "tableTo": "words", + "columnsFrom": [ + "word_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "word_reviews_user_id_users_id_fk": { + "name": "word_reviews_user_id_users_id_fk", + "tableFrom": "word_reviews", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.words": { + "name": "words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "words_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "batch_id": { + "name": "batch_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_unique_word": { + "name": "idx_unique_word", + "columns": [ + { + "expression": "word", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_word_batch_id": { + "name": "idx_word_batch_id", + "columns": [ + { + "expression": "batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "words_batch_id_batches_id_fk": { + "name": "words_batch_id_batches_id_fk", + "tableFrom": "words", + "tableTo": "batches", + "columnsFrom": [ + "batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/api/drizzle/meta/_journal.json b/api/drizzle/meta/_journal.json index 2b07185..1f29b4b 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -15,6 +15,27 @@ "when": 1751372514564, "tag": "0001_fuzzy_rattler", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1754744314082, + "tag": "0002_curved_eternals", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1767798101496, + "tag": "0003_lame_rachel_grey", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1769514003347, + "tag": "0004_oval_leopardon", + "breakpoints": true } ] } \ No newline at end of file diff --git a/api/package-lock.json b/api/package-lock.json index 35dc260..e6dfc83 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -7,43 +7,43 @@ "name": "wat-worker", "dependencies": { "common-tags": "^1.8.2", - "drizzle-orm": "0.43.1", - "hono": "^4.7.4", + "drizzle-orm": "^0.45.2", + "hono": "^4.12.23", "js-jsonl": "^1.1.1", - "openai": "^4.89.0", + "openai": "^6.42.0", "sqlstring": "^2.3.3", - "uuid": "^11.1.0" + "uuid": "^14.0.0", + "zod": "^4.4.3" }, "devDependencies": { "@types/common-tags": "^1.8.4", "@types/sqlstring": "^2.3.2", - "dotenv": "^16.5.0", - "drizzle-kit": "^0.31.1", - "postgres": "^3.4.5", - "tsx": "^4.19.4", - "wrangler": "^4.0.0" + "dotenv": "^17.4.2", + "drizzle-kit": "^0.31.10", + "postgres": "^3.4.9", + "tsx": "^4.22.4", + "wrangler": "^4.98.0" } }, "node_modules/@cloudflare/kv-asset-handler": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.0.tgz", - "integrity": "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", "dev": true, - "dependencies": { - "mime": "^3.0.0" - }, + "license": "MIT OR Apache-2.0", "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, "node_modules/@cloudflare/unenv-preset": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.3.1.tgz", - "integrity": "sha512-Xq57Qd+ADpt6hibcVBO0uLG9zzRgyRhfCUgBT9s+g3+3Ivg5zDyVgLFy40ES1VdNcu8rPNSivm9A+kGP5IVaPg==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", "dev": true, + "license": "MIT OR Apache-2.0", "peerDependencies": { - "unenv": "2.0.0-rc.15", - "workerd": "^1.20250320.0" + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" }, "peerDependenciesMeta": { "workerd": { @@ -52,102 +52,96 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20250408.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250408.0.tgz", - "integrity": "sha512-bxhIwBWxaNItZLXDNOKY2dCv0FHjDiDkfJFpwv4HvtvU5MKcrivZHVmmfDzLW85rqzfcDOmKbZeMPVfiKxdBZw==", + "version": "1.20260603.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260603.1.tgz", + "integrity": "sha512-cEXDWu6V3ZrpmwWkM4OJE9AeXjdAgOY5rh8EHhcBVCuP5rxnzUbPzLtrVOHx0UUUAcCrFq0Xsa6mZKL1VUZsKQ==", "cpu": [ "x64" ], "dev": true, + "license": "Apache-2.0", "optional": true, "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=16" } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20250408.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250408.0.tgz", - "integrity": "sha512-5XZ2Oykr8bSo7zBmERtHh18h5BZYC/6H1YFWVxEj3PtalF3+6SHsO4KZsbGvDml9Pu7sHV277jiZE5eny8Hlyw==", + "version": "1.20260603.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260603.1.tgz", + "integrity": "sha512-uBPK4LaWJNbbCYwPnUAehlHbbVulhVZPZsdcAhBPfZhHb3QAuAEPAQepO/P67R3V6Cni4YGx1fLbL8A5wwoaNA==", "cpu": [ "arm64" ], "dev": true, + "license": "Apache-2.0", "optional": true, "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=16" } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20250408.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250408.0.tgz", - "integrity": "sha512-WbgItXWln6G5d7GvYLWcuOzAVwafysZaWunH3UEfsm95wPuRofpYnlDD861gdWJX10IHSVgMStGESUcs7FLerQ==", + "version": "1.20260603.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260603.1.tgz", + "integrity": "sha512-ht9l6/8Tk7Rp6kA4S9oFZ4X8u0VjnnFdmU/6B3fnABYKREYTKh2RdOqXqXxcp5eNJseireKnWik/hQOPK1CutQ==", "cpu": [ "x64" ], "dev": true, + "license": "Apache-2.0", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=16" } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20250408.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250408.0.tgz", - "integrity": "sha512-pAhEywPPvr92SLylnQfZEPgXz+9pOG9G9haAPLpEatncZwYiYd9yiR6HYWhKp2erzCoNrOqKg9IlQwU3z1IDiw==", + "version": "1.20260603.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260603.1.tgz", + "integrity": "sha512-LJZ6x00rAjSrobV4m0ZW0TpH5ilBbKcWBzlH+y+KOUsIE/CpTuhAzKV43TbSnFLRX5+jrWKiz2v0hO91lPXy6A==", "cpu": [ "arm64" ], "dev": true, + "license": "Apache-2.0", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=16" } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20250408.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250408.0.tgz", - "integrity": "sha512-nJ3RjMKGae2aF2rZ/CNeBvQPM+W5V1SUK0FYWG/uomyr7uQ2l4IayHna1ODg/OHHTEgIjwom0Mbn58iXb0WOcQ==", + "version": "1.20260603.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260603.1.tgz", + "integrity": "sha512-DvwqkXMAJRPoDN4PxapAwhlz/6ouD+6R1ttbAEK3cWD/QBvFF5STx7Ds/9Irf+rBly3np3uHWkeX+wZnNFEuzA==", "cpu": [ "x64" ], "dev": true, + "license": "Apache-2.0", "optional": true, "os": [ "win32" ], - "peer": true, "engines": { "node": ">=16" } }, - "node_modules/@cloudflare/workers-types": { - "version": "4.20250415.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20250415.0.tgz", - "integrity": "sha512-6N3N7yZEBVhztPUcloHSMm5+kSY0/e1OtoPjNrI5Jw8dyFTNSf875u0D76YFiYCV8WVwbp3RtdEAb6TWdAwoEw==", - "optional": true, - "peer": true - }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.9" }, @@ -159,13 +153,15 @@ "version": "0.10.2", "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", - "dev": true + "dev": true, + "license": "Apache-2.0" }, "node_modules/@emnapi/runtime": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.1.tgz", - "integrity": "sha512-LMshMVP0ZhACNjQNYXiU1iZJ6QCcv0lUdPDPugqGvCGXt5xtRVBPdtA0qU12pEXZzpWAhWlZYptfdAFq10DOVQ==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, + "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" @@ -177,6 +173,7 @@ "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", "deprecated": "Merged into tsx: https://tsx.is", "dev": true, + "license": "MIT", "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" @@ -190,6 +187,7 @@ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -206,6 +204,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -222,6 +221,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -238,6 +238,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -254,6 +255,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -270,6 +272,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -286,6 +289,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -302,6 +306,7 @@ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -318,6 +323,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -334,6 +340,7 @@ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -350,6 +357,7 @@ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -366,6 +374,7 @@ "mips64el" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -382,6 +391,7 @@ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -398,6 +408,7 @@ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -414,6 +425,7 @@ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -430,6 +442,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -446,6 +459,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" @@ -462,6 +476,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -478,6 +493,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "sunos" @@ -494,6 +510,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -510,6 +527,7 @@ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -526,6 +544,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -540,6 +559,7 @@ "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -577,19 +597,21 @@ "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", "deprecated": "Merged into tsx: https://tsx.is", "dev": true, + "license": "MIT", "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz", - "integrity": "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "aix" @@ -599,13 +621,14 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.2.tgz", - "integrity": "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -615,13 +638,14 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.2.tgz", - "integrity": "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -631,13 +655,14 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.2.tgz", - "integrity": "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -647,13 +672,14 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.2.tgz", - "integrity": "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -663,13 +689,14 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.2.tgz", - "integrity": "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -679,13 +706,14 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.2.tgz", - "integrity": "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -695,13 +723,14 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.2.tgz", - "integrity": "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -711,13 +740,14 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.2.tgz", - "integrity": "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -727,13 +757,14 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.2.tgz", - "integrity": "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -743,13 +774,14 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.2.tgz", - "integrity": "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -759,13 +791,14 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.2.tgz", - "integrity": "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -775,13 +808,14 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.2.tgz", - "integrity": "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -791,13 +825,14 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.2.tgz", - "integrity": "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -807,13 +842,14 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.2.tgz", - "integrity": "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -823,13 +859,14 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.2.tgz", - "integrity": "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -839,13 +876,14 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.2.tgz", - "integrity": "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -855,13 +893,14 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.2.tgz", - "integrity": "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" @@ -871,13 +910,14 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.2.tgz", - "integrity": "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" @@ -887,13 +927,14 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.2.tgz", - "integrity": "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -903,13 +944,14 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.2.tgz", - "integrity": "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -918,14 +960,32 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.2.tgz", - "integrity": "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "sunos" @@ -935,13 +995,14 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.2.tgz", - "integrity": "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -951,13 +1012,14 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.2.tgz", - "integrity": "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -967,13 +1029,14 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.2.tgz", - "integrity": "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -982,23 +1045,25 @@ "node": ">=18" } }, - "node_modules/@fastify/busboy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", - "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "dev": true, + "license": "MIT", "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "cpu": [ "arm64" ], "dev": true, + "license": "Apache-2.0", "optional": true, "os": [ "darwin" @@ -1010,17 +1075,18 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "cpu": [ "x64" ], "dev": true, + "license": "Apache-2.0", "optional": true, "os": [ "darwin" @@ -1032,17 +1098,18 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "cpu": [ "arm64" ], "dev": true, + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "darwin" @@ -1052,13 +1119,14 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "cpu": [ "x64" ], "dev": true, + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "darwin" @@ -1068,13 +1136,17 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -1084,13 +1156,57 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -1100,13 +1216,17 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -1116,13 +1236,17 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -1132,13 +1256,17 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -1148,13 +1276,17 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -1164,13 +1296,17 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -1182,17 +1318,73 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" + "@img/sharp-libvips-linux-arm": "1.2.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -1204,17 +1396,21 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" + "@img/sharp-libvips-linux-riscv64": "1.2.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -1226,17 +1422,21 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.4" + "@img/sharp-libvips-linux-s390x": "1.2.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -1248,17 +1448,21 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" + "@img/sharp-libvips-linux-x64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -1270,17 +1474,21 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -1292,21 +1500,42 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "cpu": [ "wasm32" ], "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.2.0" + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -1315,13 +1544,14 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "cpu": [ "ia32" ], "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" @@ -1334,13 +1564,14 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ "x64" ], "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" @@ -1357,61 +1588,76 @@ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@neondatabase/serverless": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.0.0.tgz", - "integrity": "sha512-XWmEeWpBXIoksZSDN74kftfTnXFEGZ3iX8jbANWBc+ag6dsiQuvuR4LgB0WdCOKMb5AQgjqgufc0TgAsZubUYw==", - "optional": true, - "peer": true, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", "dependencies": { - "@types/node": "^22.10.2", - "@types/pg": "^8.8.0" - }, - "engines": { - "node": ">=19.0.0" + "kleur": "^4.1.5" } }, - "node_modules/@neondatabase/serverless/node_modules/@types/node": { - "version": "22.15.18", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.18.tgz", - "integrity": "sha512-v1DKRfUdyW+jJhZNEI1PYy29S2YRxMV5AOO/x/SjKmW0acCIOqmbj6Haf9eHAhsPmrhlHSxEhv/1WszcLWV4cg==", - "optional": true, - "peer": true, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" } }, - "node_modules/@neondatabase/serverless/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "optional": true, - "peer": true + "node_modules/@poppinss/dumper/node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "license": "MIT", "engines": { "node": ">=10" }, @@ -1419,253 +1665,80 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@speed-highlight/core": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", + "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/@types/common-tags": { "version": "1.8.4", "resolved": "https://registry.npmjs.org/@types/common-tags/-/common-tags-1.8.4.tgz", "integrity": "sha512-S+1hLDJPjWNDhcGxsxEbepzaxWqURP/o+3cP4aa2w7yBXgdcmKGQtZzP8JbyfOd0m+33nh+8+kvxYE2UJtBDkg==", - "dev": true - }, - "node_modules/@types/node": { - "version": "18.19.86", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.86.tgz", - "integrity": "sha512-fifKayi175wLyKyc5qUfyENhQ1dCNI1UNjp653d8kuYcPQN5JhX3dGuP/XmvPTg/xRBn1VTLpbmi+H/Mr7tLfQ==", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@types/node-fetch": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", - "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.0" - } - }, - "node_modules/@types/pg": { - "version": "8.15.2", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.2.tgz", - "integrity": "sha512-+BKxo5mM6+/A1soSHBI7ufUglqYXntChLDyTbvcAn1Lawi9J7J9Ok3jt6w7I0+T/UDJ4CyhHk66+GZbwmkYxSg==", - "optional": true, - "peer": true, - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^4.0.1" - } + "dev": true, + "license": "MIT" }, "node_modules/@types/sqlstring": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/@types/sqlstring/-/sqlstring-2.3.2.tgz", "integrity": "sha512-lVRe4Iz9UNgiHelKVo8QlC8fb5nfY8+p+jNQNE+UVsuuVlQnWhyWmQ/wF5pE8Ys6TdjfVpqTG9O9i2vi6E0+Sg==", - "dev": true - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, - "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", - "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/as-table": { - "version": "1.0.55", - "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", - "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", "dev": true, - "dependencies": { - "printable-characters": "^1.0.42" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + "license": "MIT" }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "dev": true, - "optional": true, - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "optional": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "optional": true - }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", "dev": true, - "optional": true, - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } + "license": "MIT" }, "node_modules/common-tags": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "license": "MIT", "engines": { "node": ">=4.0.0" } }, "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/data-uri-to-buffer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", - "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", - "dev": true - }, - "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, + "license": "MIT", "engines": { - "node": ">=6.0" + "node": ">=18" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/defu": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", - "dev": true - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "engines": { - "node": ">=0.4.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/detect-libc": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", - "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, - "optional": true, + "license": "Apache-2.0", "engines": { "node": ">=8" } }, "node_modules/dotenv": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", - "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=12" }, @@ -1674,24 +1747,26 @@ } }, "node_modules/drizzle-kit": { - "version": "0.31.1", - "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.1.tgz", - "integrity": "sha512-PUjYKWtzOzPtdtQlTHQG3qfv4Y0XT8+Eas6UbxCmxTj7qgMf+39dDujf1BP1I+qqZtw9uzwTh8jYtkMuCq+B0Q==", + "version": "0.31.10", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", + "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", "dev": true, + "license": "MIT", "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", - "esbuild": "^0.25.2", - "esbuild-register": "^3.5.0" + "esbuild": "^0.25.4", + "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "node_modules/drizzle-orm": { - "version": "0.43.1", - "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.43.1.tgz", - "integrity": "sha512-dUcDaZtE/zN4RV/xqGrVSMpnEczxd5cIaoDeor7Zst9wOe/HzC/7eAaulywWGYXdDEc9oBPMjayVEDg0ziTLJA==", + "version": "0.45.2", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", + "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", + "license": "Apache-2.0", "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", @@ -1707,6 +1782,7 @@ "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", @@ -1764,6 +1840,9 @@ "@types/sql.js": { "optional": true }, + "@upstash/redis": { + "optional": true + }, "@vercel/postgres": { "optional": true }, @@ -1808,66 +1887,23 @@ } } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" } }, "node_modules/esbuild": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz", - "integrity": "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -1875,108 +1911,41 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.2", - "@esbuild/android-arm": "0.25.2", - "@esbuild/android-arm64": "0.25.2", - "@esbuild/android-x64": "0.25.2", - "@esbuild/darwin-arm64": "0.25.2", - "@esbuild/darwin-x64": "0.25.2", - "@esbuild/freebsd-arm64": "0.25.2", - "@esbuild/freebsd-x64": "0.25.2", - "@esbuild/linux-arm": "0.25.2", - "@esbuild/linux-arm64": "0.25.2", - "@esbuild/linux-ia32": "0.25.2", - "@esbuild/linux-loong64": "0.25.2", - "@esbuild/linux-mips64el": "0.25.2", - "@esbuild/linux-ppc64": "0.25.2", - "@esbuild/linux-riscv64": "0.25.2", - "@esbuild/linux-s390x": "0.25.2", - "@esbuild/linux-x64": "0.25.2", - "@esbuild/netbsd-arm64": "0.25.2", - "@esbuild/netbsd-x64": "0.25.2", - "@esbuild/openbsd-arm64": "0.25.2", - "@esbuild/openbsd-x64": "0.25.2", - "@esbuild/sunos-x64": "0.25.2", - "@esbuild/win32-arm64": "0.25.2", - "@esbuild/win32-ia32": "0.25.2", - "@esbuild/win32-x64": "0.25.2" - } - }, - "node_modules/esbuild-register": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", - "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", - "dev": true, - "dependencies": { - "debug": "^4.3.4" - }, - "peerDependencies": { - "esbuild": ">=0.12 <1" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "engines": { - "node": ">=6" - } - }, - "node_modules/exit-hook": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", - "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", - "dev": true, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/exsolve": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.4.tgz", - "integrity": "sha512-xsZH6PXaER4XoV+NiT7JHp1bJodJVT+cxeSH1G0f0tlT0lJqYuHUP3bUx2HtfTDvOagMINYp8rsqusxud3RXhw==", - "dev": true - }, - "node_modules/form-data": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", - "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/form-data-encoder": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", - "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==" - }, - "node_modules/formdata-node": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", - "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", - "dependencies": { - "node-domexception": "1.0.0", - "web-streams-polyfill": "4.0.0-beta.3" - }, - "engines": { - "node": ">= 12.20" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -1985,64 +1954,12 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-source": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", - "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", - "dev": true, - "dependencies": { - "data-uri-to-buffer": "^2.0.0", - "source-map": "^0.6.1" - } - }, "node_modules/get-tsconfig": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.0.tgz", - "integrity": "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", + "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", "dev": true, + "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" }, @@ -2050,212 +1967,63 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/hono": { - "version": "4.7.6", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.7.6.tgz", - "integrity": "sha512-564rVzELU+9BRqqx5k8sT2NFwGD3I3Vifdb6P7CmM6FiarOSY+fDC+6B+k9wcCb86ReoayteZP2ki0cRLN1jbw==", + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "license": "MIT", "engines": { "node": ">=16.9.0" } }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "dependencies": { - "ms": "^2.0.0" - } - }, - "node_modules/is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", - "dev": true, - "optional": true - }, "node_modules/js-jsonl": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/js-jsonl/-/js-jsonl-1.1.1.tgz", "integrity": "sha512-VkkV3ac6N6tRaK32NIaXStzs9l3py/XK5pCbTEyiUt5Ch5We3H8ZcrSQndQ4TyIisfKMIjvoiTNWsb7mhQcZZw==", + "license": "MIT", "dependencies": { "@sindresorhus/is": "^4.6.0" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mime": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", - "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", "dev": true, - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=6" } }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "node_modules/miniflare": { + "version": "4.20260603.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260603.0.tgz", + "integrity": "sha512-+kMQYB82gC8MPOuojHur3icQsUeZUEJ+Sphuo5rVC3Ri9txBLAW/mH33b9OVrpmkogQeaaqPS4tPtugJZhk5Kw==", + "dev": true, + "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.34.5", + "undici": "7.24.8", + "workerd": "1.20260603.1", + "ws": "8.20.1", + "youch": "4.1.0-beta.10" }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "node_modules/mustache": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", - "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", - "dev": true, "bin": { - "mustache": "bin/mustache" - } - }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dependencies": { - "whatwg-url": "^5.0.0" + "miniflare": "bootstrap.js" }, "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } + "node": ">=22.0.0" } }, - "node_modules/obuf": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", - "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", - "optional": true, - "peer": true - }, - "node_modules/ohash": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", - "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", - "dev": true - }, "node_modules/openai": { - "version": "4.94.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-4.94.0.tgz", - "integrity": "sha512-WVmr9HWcwfouLJ7R3UHd2A93ClezTPuJljQxkCYQAL15Sjyt+FBNoqEz5MHSdH/ebQrVyvRhFyn/bvdqtSPyIA==", - "dependencies": { - "@types/node": "^18.11.18", - "@types/node-fetch": "^2.6.4", - "abort-controller": "^3.0.0", - "agentkeepalive": "^4.2.1", - "form-data-encoder": "1.7.2", - "formdata-node": "^4.3.2", - "node-fetch": "^2.6.7" - }, - "bin": { - "openai": "bin/cli" - }, + "version": "6.42.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.42.0.tgz", + "integrity": "sha512-1WFEt/uXMXOLhYRNkgJWo08Y2YNvNwpVU72K7ibrWgWpNOXd4VojXLbe6SQ4bLiUQ3Y8jz4IiyVkylJCL1DtZg==", + "license": "Apache-2.0", "peerDependencies": { "ws": "^8.18.0", - "zod": "^3.23.8" + "zod": "^3.25 || ^4.0" }, "peerDependenciesMeta": { "ws": { @@ -2270,65 +2038,22 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true - }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "optional": true, - "peer": true, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-numeric": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz", - "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==", - "optional": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/pg-protocol": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.0.tgz", - "integrity": "sha512-IpdytjudNuLv8nhlHs/UrVBhU0e78J0oIS/0AVdTbWxSOkFUVdsHC/NrorO6nXsQNDTT1kzDSOMJubBQviX18Q==", - "optional": true, - "peer": true - }, - "node_modules/pg-types": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", - "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", - "optional": true, - "peer": true, - "dependencies": { - "pg-int8": "1.0.1", - "pg-numeric": "1.0.2", - "postgres-array": "~3.0.1", - "postgres-bytea": "~3.0.0", - "postgres-date": "~2.1.0", - "postgres-interval": "^3.0.0", - "postgres-range": "^1.1.1" - }, - "engines": { - "node": ">=10" - } + "dev": true, + "license": "MIT" }, "node_modules/postgres": { - "version": "3.4.5", - "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.5.tgz", - "integrity": "sha512-cDWgoah1Gez9rN3H4165peY9qfpEo+SA61oQv65O3cRUE1pOEoJWwddwcqKE8XZYjbblOJlYDlLV4h67HrEVDg==", + "version": "3.4.9", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", + "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", "devOptional": true, + "license": "Unlicense", "engines": { "node": ">=12" }, @@ -2337,77 +2062,22 @@ "url": "https://github.com/sponsors/porsager" } }, - "node_modules/postgres-array": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", - "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/postgres-bytea": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", - "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", - "optional": true, - "peer": true, - "dependencies": { - "obuf": "~1.1.2" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/postgres-date": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", - "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/postgres-interval": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", - "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/postgres-range": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz", - "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==", - "optional": true, - "peer": true - }, - "node_modules/printable-characters": { - "version": "1.0.42", - "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", - "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", - "dev": true - }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", "dev": true, - "optional": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -2416,16 +2086,16 @@ } }, "node_modules/sharp": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", - "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "dev": true, "hasInstallScript": true, - "optional": true, + "license": "Apache-2.0", "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.6.3" + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -2434,35 +2104,30 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5" - } - }, - "node_modules/simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "dev": true, - "optional": true, - "dependencies": { - "is-arrayish": "^0.3.1" + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" } }, "node_modules/source-map": { @@ -2470,6 +2135,7 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -2479,6 +2145,7 @@ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, + "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -2488,50 +2155,40 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, - "node_modules/stacktracey": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.1.8.tgz", - "integrity": "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==", - "dev": true, - "dependencies": { - "as-table": "^1.0.36", - "get-source": "^2.0.12" - } - }, - "node_modules/stoppable": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", - "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", "dev": true, + "license": "MIT", "engines": { - "node": ">=4", - "npm": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, + "license": "0BSD", "optional": true }, "node_modules/tsx": { - "version": "4.19.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.4.tgz", - "integrity": "sha512-gK5GVzDkJK1SI1zwHf32Mqxf2tSJkNx+eYcNly5+nHvWqXUJYUkWBQtKauoESz3ymezAI++ZwT855x5p5eop+Q==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, + "license": "MIT", "dependencies": { - "esbuild": "~0.25.0", - "get-tsconfig": "^4.7.5" + "esbuild": "~0.28.0" }, "bin": { "tsx": "dist/cli.mjs" @@ -2543,272 +2200,1069 @@ "fsevents": "~2.3.3" } }, - "node_modules/ufo": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", - "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", - "dev": true - }, - "node_modules/undici": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", - "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", - "dev": true, - "dependencies": { - "@fastify/busboy": "^2.0.0" - }, - "engines": { - "node": ">=14.0" - } - }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" - }, - "node_modules/unenv": { - "version": "2.0.0-rc.15", - "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.15.tgz", - "integrity": "sha512-J/rEIZU8w6FOfLNz/hNKsnY+fFHWnu9MH4yRbSZF3xbbGHovcetXPs7sD+9p8L6CeNC//I9bhRYAOsBt2u7/OA==", - "dev": true, - "dependencies": { - "defu": "^6.1.4", - "exsolve": "^1.0.4", - "ohash": "^2.0.11", - "pathe": "^2.0.3", - "ufo": "^1.5.4" - } - }, - "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" ], - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, - "node_modules/web-streams-polyfill": { - "version": "4.0.0-beta.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", - "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", - "engines": { - "node": ">= 14" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/workerd": { - "version": "1.20250408.0", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250408.0.tgz", - "integrity": "sha512-bBUX+UsvpzAqiWFNeZrlZmDGddiGZdBBbftZJz2wE6iUg/cIAJeVQYTtS/3ahaicguoLBz4nJiDo8luqM9fx1A==", "dev": true, - "hasInstallScript": true, + "license": "MIT", "optional": true, - "peer": true, - "bin": { - "workerd": "bin/workerd" - }, - "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20250408.0", - "@cloudflare/workerd-darwin-arm64": "1.20250408.0", - "@cloudflare/workerd-linux-64": "1.20250408.0", - "@cloudflare/workerd-linux-arm64": "1.20250408.0", - "@cloudflare/workerd-windows-64": "1.20250408.0" - } - }, - "node_modules/wrangler": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.11.0.tgz", - "integrity": "sha512-P9jyp1wDUmspi9sRMQG16TyeiN8IP7pLkfeknRkqm/3AwkD8L32AvgqDYqk/jZhI+eIGAzue5h1JX6jILX2qBQ==", - "dev": true, - "dependencies": { - "@cloudflare/kv-asset-handler": "0.4.0", - "@cloudflare/unenv-preset": "2.3.1", - "blake3-wasm": "2.1.5", - "esbuild": "0.25.2", - "miniflare": "4.20250410.0", - "path-to-regexp": "6.3.0", - "unenv": "2.0.0-rc.15", - "workerd": "1.20250410.0" - }, - "bin": { - "wrangler": "bin/wrangler.js", - "wrangler2": "bin/wrangler.js" - }, + "os": [ + "aix" + ], "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2", - "sharp": "^0.33.5" - }, - "peerDependencies": { - "@cloudflare/workers-types": "^4.20250410.0" - }, - "peerDependenciesMeta": { - "@cloudflare/workers-types": { - "optional": true - } + "node": ">=18" } }, - "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20250410.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250410.0.tgz", - "integrity": "sha512-U3Pb+pr6DYeESXGiDAS/SKTVBoDA/BbFyiTTi4BJSdk2I703vaDJGH4k1jFsOJmb/S+Gy7w5xVJwHgZA7w6Kaw==", + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", "cpu": [ - "x64" + "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ - "darwin" + "android" ], "engines": { - "node": ">=16" + "node": ">=18" } }, - "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20250410.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250410.0.tgz", - "integrity": "sha512-stvgUOVDXGBdV1HTEdgcQ/d/c9T4Vrmg+8OJK4HXn6uM1spMediyQXed64greG0I5rNJ0pTPq0uZsfCjn9T7cA==", + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ - "darwin" + "android" ], "engines": { - "node": ">=16" + "node": ">=18" } }, - "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20250410.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250410.0.tgz", - "integrity": "sha512-n7BRelPUc7+UNVKlS7z/uhI6xqsoyoZayjrezLcZ54IY4o9XASt4oc4kFDrc5ow9YNW94sq2tBG8z/ZQ9wBjLA==", + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" + "android" ], "engines": { - "node": ">=16" + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/undici": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", + "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/uuid": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/workerd": { + "version": "1.20260603.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260603.1.tgz", + "integrity": "sha512-NPcbhI1++CS+fnELyXtsIR52en+5kwr/OrKeiQeYXGy10HxmPdsQBv9N+DU7hJIOOmBHhOGAAsoGDjyiQ2YCaA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260603.1", + "@cloudflare/workerd-darwin-arm64": "1.20260603.1", + "@cloudflare/workerd-linux-64": "1.20260603.1", + "@cloudflare/workerd-linux-arm64": "1.20260603.1", + "@cloudflare/workerd-windows-64": "1.20260603.1" + } + }, + "node_modules/wrangler": { + "version": "4.98.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.98.0.tgz", + "integrity": "sha512-cXfFUuF4rMIvE0hiMnXjEAB27ERryaCgquBJdUoPIjFzYYE1rbRdMUkEdQ18qDPUtsPvhJdqxLntixT9OfSzQw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.27.3", + "miniflare": "4.20260603.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260603.1" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260603.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20250410.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250410.0.tgz", - "integrity": "sha512-R9zE5LhVQCgIPIB/LWSt+XqFjzkPRfkLk4pqae8OYhMg0Dr3G7xO/xNxIabCVNPHhF7dj2yXfMyLGX603Y8/LQ==", + "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=16" + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, - "node_modules/wrangler/node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20250410.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250410.0.tgz", - "integrity": "sha512-UcoQ2u+TQcKNEQUXBRsoqfXibOCklCilukN5xxz7svuAi1f2P0/lR29iuq6DBEwDu/GqQd6H5WLrczXwfPLZsQ==", + "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=16" + "node": ">=18" } }, - "node_modules/wrangler/node_modules/miniflare": { - "version": "4.20250410.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20250410.0.tgz", - "integrity": "sha512-vvbVssljSZathZ0gYQknXW4g/Oye/jAYlQStnbW+8Sa3BnF0u+vhP3MaCGea/OlC9+FsD/2HUwVnb56Tof9UZA==", + "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], "dev": true, - "dependencies": { - "@cspotcode/source-map-support": "0.8.1", - "acorn": "8.14.0", - "acorn-walk": "8.3.2", - "exit-hook": "2.2.1", - "glob-to-regexp": "0.4.1", - "stoppable": "1.1.0", - "undici": "^5.28.5", - "workerd": "1.20250410.0", - "ws": "8.18.0", - "youch": "3.3.4", - "zod": "3.22.3" - }, - "bin": { - "miniflare": "bootstrap.js" - }, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=18.0.0" + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/wrangler/node_modules/workerd": { - "version": "1.20250410.0", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250410.0.tgz", - "integrity": "sha512-W7vy1+Z3+jpLr68nmda3VFapn284VwIb22TZlI1LbCvThAiNABIa/t06bTE9mohSjUudFou9rc9de49fUvrUDQ==", + "node_modules/wrangler/node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { - "workerd": "bin/workerd" + "esbuild": "bin/esbuild" }, "engines": { - "node": ">=16" + "node": ">=18" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20250410.0", - "@cloudflare/workerd-darwin-arm64": "1.20250410.0", - "@cloudflare/workerd-linux-64": "1.20250410.0", - "@cloudflare/workerd-linux-arm64": "1.20250410.0", - "@cloudflare/workerd-windows-64": "1.20250410.0" - } - }, - "node_modules/wrangler/node_modules/zod": { - "version": "3.22.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", - "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" } }, "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "devOptional": true, + "license": "MIT", "engines": { "node": ">=10.0.0" }, @@ -2826,22 +3280,35 @@ } }, "node_modules/youch": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz", - "integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==", + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", "dev": true, + "license": "MIT", "dependencies": { - "cookie": "^0.7.1", - "mustache": "^4.2.0", - "stacktracey": "^2.1.8" + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" } }, "node_modules/zod": { - "version": "3.24.2", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", - "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", - "optional": true, - "peer": true, + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/api/package.json b/api/package.json index e281cae..b76cc26 100644 --- a/api/package.json +++ b/api/package.json @@ -5,24 +5,25 @@ "deploy": "wrangler deploy --minify", "cf-typegen": "wrangler types --env-interface CloudflareBindings", "db:generate": "drizzle-kit generate", - "db:migrate": "tsx migrate.ts" + "db:migrate": "drizzle-kit migrate" }, "dependencies": { "common-tags": "^1.8.2", - "drizzle-orm": "0.43.1", - "hono": "^4.7.4", + "drizzle-orm": "^0.45.2", + "hono": "^4.12.23", "js-jsonl": "^1.1.1", - "openai": "^4.89.0", + "openai": "^6.42.0", "sqlstring": "^2.3.3", - "uuid": "^11.1.0" + "uuid": "^14.0.0", + "zod": "^4.4.3" }, "devDependencies": { "@types/common-tags": "^1.8.4", "@types/sqlstring": "^2.3.2", - "dotenv": "^16.5.0", - "drizzle-kit": "^0.31.1", - "postgres": "^3.4.5", - "tsx": "^4.19.4", - "wrangler": "^4.0.0" + "dotenv": "^17.4.2", + "drizzle-kit": "^0.31.10", + "postgres": "^3.4.9", + "tsx": "^4.22.4", + "wrangler": "^4.98.0" } } diff --git a/api/src/ai-client.ts b/api/src/ai-client.ts index 63217c5..6d31d1c 100644 --- a/api/src/ai-client.ts +++ b/api/src/ai-client.ts @@ -1,59 +1,44 @@ import OpenAI from "openai"; import { oneLine } from "common-tags"; import { BatchError, ChatResponse } from "./types"; +import { zodResponseFormat } from "openai/helpers/zod"; +import { z } from "zod"; + +const AiResponse = z.object({ + word: z.string(), + status: z.number(), +}); + +const AiResponseArray = z.object({ + responses: z.array(AiResponse), +}); export default class AiClient { private env: CloudflareBindings; private baseUrl: string; private models = { - openai: [ - "gpt-4.1", - "gpt-4.1-mini", - "gpt-4.1-nano", - "gpt-4o", - "gpt-4o-mini", - "gpt-4-turbo", - ], + openai: ["gpt-5.4-mini", "gpt-5.4-nano"], anthropic: [ - "claude-3-7-sonnet-latest", - "claude-3-5-sonnet-latest", - "claude-3-5-haiku-latest", - "claude-3-opus-latest", + "claude-haiku-4-5", + "claude-sonnet-4-6", ], - // qwen: [ - // "qwen2.5-7b-instruct", - // "qwen2.5-14b-instruct", - // "qwen-max", - // "qwen-plus", - // "qwen-turbo", - // ], mistral: [ - "ministral-3b-latest", - "codestral-latest", + "mistral-small-latest", + "mistral-medium-latest", "mistral-large-latest", - "pixtral-large-latest", "ministral-8b-latest", + "ministral-14b-latest", ], }; - private systemPrompt: string = oneLine`You are a language expert who is checking spelling. - You will be given a list of words and a language and you will respond with - whether the words exist in the language and whether they are proper names. - You will respond with JSON, like this: - [ - { - "word": "TestWord1", - "status": 0 - }, - { - "word": "TestWord2", - "status": 1 - } - ]. - Where status: 0 - doesn't exist, 1 - exists, 2 - proper name. - Give no other commentary. - Here are the language and words to test.`; + private systemPrompt: string = oneLine`You are a Senior {language} Linguist specializing in orthography and corpus linguistics. + You will be given a list of words in this language and you will check each word against related dictionary, + taking into account declensions and endings. + Respond with a status for each word: 1 = exists, 0 = doesn't exist, 2 = proper name. + Don treat capitalized words as proper names unless confirmed by dictionary. + Do not correct misspellings. Do not duplicate words in your response. + Treat capitalized and lowercase versions of the same word as distinct entries.`; constructor(env: CloudflareBindings) { this.env = env; @@ -62,7 +47,8 @@ export default class AiClient { async chat( model: string, - prompt: string + language: string, + prompt: string, ): Promise { const client = this.getClient(model); @@ -70,35 +56,47 @@ export default class AiClient { return Promise.reject("model is invalid"); } - const response = await client.chat.completions.create({ - model: model, - messages: [ - { - role: "system", - content: this.systemPrompt, - }, - { - role: "user", - content: prompt, - }, - ], - }); + let response = null; try { - let result = response.choices[0].message.content || "[]"; - const json = this.extractJson(result); + response = await client.chat.completions.parse({ + model: model, + messages: [ + { + role: "system", + content: this.systemPrompt.replace("{language}", language), + }, + { + role: "user", + content: prompt, + }, + ], + response_format: zodResponseFormat(AiResponseArray, "responses"), + }); - if (json == null) { - throw new Error("invalid json response"); + let result = response.choices[0].message; + + if (result.refusal) { + throw new Error(result.refusal); + } + + if (result.parsed === null) { + throw new Error("Could not parse AI response"); } - return JSON.parse(json); + return result.parsed.responses.map((response) => { + const status: ChatResponse = { + word: response.word, + status: response.status, + }; + return status; + }); } catch (error) { return { prompt, message: error instanceof Error ? error.message : String(error), model, - response: response.choices[0].message.content, + response: response?.choices[0].message.content || null, }; } } @@ -120,12 +118,6 @@ export default class AiClient { baseURL: `${this.baseUrl}/mistral`, }); } - // } else if (this.models.qwen.includes(model)) { - // return new OpenAI({ - // apiKey: this.env.QWEN_API_KEY, - // baseURL: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/", - // }); - // } else { return null; } diff --git a/api/src/constants.ts b/api/src/constants.ts index 26f4923..40c6827 100644 --- a/api/src/constants.ts +++ b/api/src/constants.ts @@ -1,3 +1,3 @@ -export const WORDS_PER_BATCH = 100; +export const WORDS_PER_BATCH = 200; export const SQL_BATCH_LIMIT = 1000; export const BATCH_MAX_RETRIES = 20; diff --git a/api/src/db.ts b/api/src/db.ts index bd5ef73..fbf12f0 100644 --- a/api/src/db.ts +++ b/api/src/db.ts @@ -1,9 +1,9 @@ import { SQL_BATCH_LIMIT } from "./constants"; -import { BatchError, ModelResult } from "./types"; +import { BatchError, ModelResult, WordData } from "./types"; import * as schema from "./db/schema"; import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; -import { and, eq, inArray, isNull, lte, sql } from "drizzle-orm"; +import { and, eq, exists, inArray, isNull, lte, not, sql } from "drizzle-orm"; export default class DbHelper { private db; @@ -17,11 +17,12 @@ export default class DbHelper { return this.db; } - async insertWords(words: string[], batchId: string) { + async insertWords(words: WordData[], batchId: string) { for (let i = 0; i < words.length; i += SQL_BATCH_LIMIT) { const batch = words.slice(i, i + SQL_BATCH_LIMIT); const wordValues = batch.map((word) => ({ - word: word, + word: word.word, + ref: word.ref, batchId: batchId, })); @@ -36,7 +37,7 @@ export default class DbHelper { } } - async fetchWordIds(words: string[], batchId: string): Promise { + async fetchWordIds(words: WordData[], batchId: string): Promise { const wordIds = []; for (let i = 0; i < words.length; i += SQL_BATCH_LIMIT) { const batch = words.slice(i, i + SQL_BATCH_LIMIT); @@ -49,8 +50,19 @@ export default class DbHelper { .where( and( eq(schema.wordsTable.batchId, batchId), - inArray(schema.wordsTable.word, batch) - ) + inArray( + schema.wordsTable.word, + batch.map((w) => w.word), + ), + not( + exists( + this.db + .select({ id: schema.modelsTable.id }) + .from(schema.modelsTable) + .where(eq(schema.modelsTable.wordId, schema.wordsTable.id)), + ), + ), + ), ); wordIds.push(...result.map((row) => row.id)); @@ -69,7 +81,7 @@ export default class DbHelper { model: model, status: -1, wordId: wordId, - })) + })), ); if (modelValuesBatch.length > 0) { await this.db @@ -84,65 +96,43 @@ export default class DbHelper { } async updateModelResults( - words: string[], batchId: string, - results: ModelResult[] + results: ModelResult[], ): Promise { - const wordsSet = new Set(words); - const modelNames: string[] = []; - const wordStatusMap = new Map(); - for (const modelResult of results) { - const modelName = modelResult.model; - if (!modelNames.includes(modelName)) { - modelNames.push(modelName); - } + const statusCases: Array> = []; + const updatedWords: string[] = []; for (const result of modelResult.results) { const word = result.word.trim(); - if (wordsSet.has(word)) { - wordStatusMap.set(word, result.status); - } else { - return { - message: `Model returned a result for word "${word}" which was not in the list.`, - prompt: null, - model: modelName, - response: null, - }; - } + + statusCases.push( + sql`WHEN ${schema.wordsTable.word} = ${word} THEN ${result.status}`, + ); + updatedWords.push(word); } - } - if (modelNames.length === 0 || wordStatusMap.size === 0) { - return { - message: "Batch did not return any results.", - prompt: null, - model: null, - response: null, - }; - } + if (statusCases.length > 0) { + const statusFragment = sql.join(statusCases, sql` `); - const caseWhenParts: Array> = []; - for (const [word, status] of wordStatusMap.entries()) { - caseWhenParts.push(sql`WHEN ${word} THEN ${status}`); + await this.db + .update(schema.modelsTable) + .set({ + status: sql`CASE ${statusFragment} ELSE ${schema.modelsTable.status} END`, + retries: modelResult.retries, + }) + .from(schema.wordsTable) + .where( + and( + eq(schema.modelsTable.wordId, schema.wordsTable.id), + eq(schema.modelsTable.model, modelResult.model), + eq(schema.wordsTable.batchId, batchId), + inArray(schema.wordsTable.word, updatedWords), // Only touch words returned by this model + ), + ); + } } - const caseWhenFragment = sql.join(caseWhenParts, sql` `); - - await this.db - .update(schema.modelsTable) - .set({ - status: sql`CASE ${schema.wordsTable.word} ${caseWhenFragment} ELSE ${schema.modelsTable.status} END`, - }) - .from(schema.wordsTable) - .where( - and( - eq(schema.modelsTable.wordId, schema.wordsTable.id), - inArray(schema.modelsTable.model, modelNames), - eq(schema.wordsTable.batchId, batchId) - ) - ); - return null; } @@ -156,14 +146,14 @@ export default class DbHelper { schema.modelsTable, and( eq(schema.wordsTable.id, schema.modelsTable.wordId), - lte(schema.modelsTable.status, -1) - ) + lte(schema.modelsTable.status, -1), + ), ) .where( and( eq(schema.wordsTable.batchId, batchId), - isNull(schema.modelsTable.id) - ) + isNull(schema.modelsTable.id), + ), ); return result[0].count; diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index 10aef44..7f0fc36 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema.ts @@ -24,7 +24,7 @@ export const usersTable = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }, - (table) => [uniqueIndex("idx_unique_user").on(table.email)] + (table) => [uniqueIndex("idx_unique_user").on(table.email)], ); export const batchesTable = pgTable( @@ -46,7 +46,7 @@ export const batchesTable = pgTable( (table) => [ uniqueIndex("idx_unique_batch").on(table.ietfCode, table.resourceType), index("idx_batch_user_id").on(table.userId), - ] + ], ); export const wordsTable = pgTable( @@ -57,14 +57,13 @@ export const wordsTable = pgTable( batchId: varchar("batch_id", { length: 255 }) .notNull() .references(() => batchesTable.id, { onDelete: "cascade" }), - correct: boolean("correct"), + ref: varchar("ref", { length: 20 }).default("").notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), }, (table) => [ uniqueIndex("idx_unique_word").on(table.word, table.batchId), index("idx_word_batch_id").on(table.batchId), - index("idx_word_correct").on(table.correct), - ] + ], ); export const modelsTable = pgTable( @@ -73,6 +72,7 @@ export const modelsTable = pgTable( id: integer("id").primaryKey().generatedAlwaysAsIdentity(), model: varchar("model", { length: 255 }).notNull(), status: integer("status").notNull(), + retries: integer("retries").default(0).notNull(), wordId: integer("word_id") .notNull() .references(() => wordsTable.id, { onDelete: "cascade" }), @@ -81,7 +81,24 @@ export const modelsTable = pgTable( (table) => [ uniqueIndex("idx_unique_model").on(table.model, table.wordId), index("idx_model_word_id").on(table.wordId), - ] + ], +); + +export const wordReviewsTable = pgTable( + "word_reviews", + { + pk: integer("pk").primaryKey().generatedAlwaysAsIdentity(), + wordId: integer("word_id") + .notNull() + .references(() => wordsTable.id, { onDelete: "cascade" }), + userId: integer("user_id") + .notNull() + .references(() => usersTable.id, { onDelete: "cascade" }), + correct: boolean("correct").notNull(), + }, + (table) => [ + uniqueIndex("idx_unique_word_review").on(table.wordId, table.userId), + ], ); export const userRelations = relations(usersTable, ({ many }) => ({ @@ -102,6 +119,7 @@ export const wordRelations = relations(wordsTable, ({ one, many }) => ({ references: [batchesTable.id], }), models: many(modelsTable), + reviews: many(wordReviewsTable), })); export const modelRelations = relations(modelsTable, ({ one }) => ({ @@ -110,3 +128,14 @@ export const modelRelations = relations(modelsTable, ({ one }) => ({ references: [wordsTable.id], }), })); + +export const wordReviewsRelations = relations(wordReviewsTable, ({ one }) => ({ + word: one(wordsTable, { + fields: [wordReviewsTable.wordId], + references: [wordsTable.id], + }), + user: one(usersTable, { + fields: [wordReviewsTable.userId], + references: [usersTable.id], + }), +})); diff --git a/api/src/index.ts b/api/src/index.ts index 7b05fc2..d310387 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -5,28 +5,55 @@ import type { JwtVariables } from "hono/jwt"; import { jwt, sign } from "hono/jwt"; import { v4 as uuid4 } from "uuid"; import AiClient from "./ai-client"; -import { isAdmin, isChatError, splitBatchJson } from "./utils"; +import { isAdmin, isChatError } from "./utils"; import { Batch, BatchDetails, BatchProgress, BatchStatus, - ChatResponse, - ModelResponse, ModelResult, PublicUser, WordResponse, BatchError, + WordsParams, + WordData, + ChatResponse, } from "./types"; -import { - BATCH_MAX_RETRIES, - SQL_BATCH_LIMIT, - WORDS_PER_BATCH, -} from "./constants"; +import { BATCH_MAX_RETRIES, WORDS_PER_BATCH } from "./constants"; import DbHelper from "./db"; import { stream } from "hono/streaming"; -import { batchesTable, modelsTable, usersTable, wordsTable } from "./db/schema"; -import { and, eq, exists, gt, sql, asc, count } from "drizzle-orm"; +import { + batchesTable, + modelsTable, + usersTable, + wordReviewsTable, + wordsTable, +} from "./db/schema"; +import { + and, + eq, + exists, + gt, + sql, + asc, + count, + inArray, + max, + min, +} from "drizzle-orm"; +import { unionAll } from "drizzle-orm/pg-core"; + +const emptyProgress: BatchProgress = { + correct: 0, + incorrect: 0, + name: 0, + review_needed: 0, + reviewed: 0, + completed: 0, + total: 0, +}; + +type WordEntity = typeof wordsTable.$inferSelect; interface AppVariables extends JwtVariables { db: DbHelper; @@ -36,6 +63,7 @@ const app = new Hono<{ Bindings: CloudflareBindings; Variables: AppVariables; }>(); + app.use("*", cors()); app.use("*", async (c, next) => { @@ -47,14 +75,11 @@ app.use("*", async (c, next) => { app.use("/api/*", async (c, next) => { const jwtMiddleware = jwt({ secret: c.env.JWT_SECRET_KEY, + alg: "HS256", }); return jwtMiddleware(c, next); }); -app.get("/", async (c) => { - return c.env.ASSETS.fetch(c.req.url); -}); - app.get("/auth/tokens/:state", async (c) => { const state = c.req.param("state"); const dbHelper = c.get("db"); @@ -64,7 +89,7 @@ app.get("/auth/tokens/:state", async (c) => { const user = await dbHelper.getDb().query.usersTable.findFirst({ where: and( eq(usersTable.state, state), - gt(usersTable.updatedAt, thirtyMinutesAgo) + gt(usersTable.updatedAt, thirtyMinutesAgo), ), }); @@ -85,7 +110,7 @@ app.get("/auth/tokens/:state", async (c) => { username: user.username, email: user.email, admin: isAdmin(user.username, c.env), - exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24, // expires in 1 day + exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7, // expires in 7 days }; return c.json({ @@ -116,7 +141,7 @@ app.get("/auth/callback", async (c) => { client_secret: c.env.WACS_SECRET, code: params.code, scope: encodeURIComponent( - "openid email profile read:user write:repository" + "openid email profile read:user write:repository", ), grant_type: "authorization_code", redirect_uri: c.env.WACS_CALLBACK, @@ -219,7 +244,7 @@ app.post("/api/batch/:ietf_code/:resource_type", async (c) => { const text = await new Response(body).text(); const json = await JSON.parse(text); const models: string[] = json.models || []; - const words: string[] = json.words || []; + const words: WordData[] = json.words || []; const language: string = json.language || null; if (models.length === 0) { @@ -255,7 +280,7 @@ app.post("/api/batch/:ietf_code/:resource_type", async (c) => { (await dbHelper.getDb().query.batchesTable.findFirst({ where: and( eq(batchesTable.ietfCode, ietf_code), - eq(batchesTable.resourceType, resource_type) + eq(batchesTable.resourceType, resource_type), ), columns: { id: true, @@ -293,16 +318,14 @@ app.post("/api/batch/:ietf_code/:resource_type", async (c) => { .where(eq(batchesTable.id, batchId)); } - // Reset current words await dbHelper.insertWords(words, batchId); const wordIds = await dbHelper.fetchWordIds(words, batchId); - // Reset model results await dbHelper.insertModels(wordIds, models); const progress: BatchProgress = { - completed: 0, + ...emptyProgress, total: words.length, }; @@ -329,19 +352,120 @@ app.post("/api/batch/:ietf_code/:resource_type", async (c) => { } }); -app.get("/api/batch/:ietf_code/:resource_type", async (c) => { +app.get("/api/report/:ietf_code/:resource_type", async (c) => { const dbHelper = c.get("db"); try { const ietf_code = c.req.param("ietf_code"); const resource_type = c.req.param("resource_type"); - const payload = c.get("jwtPayload"); - // TODO Get for current user (AND u.email = ? - payload.email) const dbBatch = await dbHelper.getDb().query.batchesTable.findFirst({ where: and( eq(batchesTable.ietfCode, ietf_code), - eq(batchesTable.resourceType, resource_type) + eq(batchesTable.resourceType, resource_type), + ), + columns: { id: true }, + }); + + if (!dbBatch) { + throw new HTTPException(404, { message: "batch not found" }); + } + + const words = await dbHelper.getDb().query.wordsTable.findMany({ + where: eq(wordsTable.batchId, dbBatch.id), + with: { + models: { + orderBy: [asc(modelsTable.model)], + }, + reviews: true, + }, + orderBy: [asc(wordsTable.word)], + }); + + const statusMap: { [key: number]: string } = { + 0: "Likely Incorrect", + 1: "Likely Correct", + 2: "Name", + [-1]: "Not Processed", + }; + + const getConsensus = (st: number[]) => { + if (st.every((s) => s === 0)) return "Likely Incorrect"; + if (st.every((s) => s === 1)) return "Likely Correct"; + if (st.every((s) => s === 2)) return "Name"; + return "Review Needed"; + }; + + c.header("Content-Type", "text/csv"); + c.header("Content-Disposition", 'attachment; filename="report.csv"'); + + return stream(c, async (s) => { + // Header + await s.write( + "word,book,chapter,verse,model1,model2,model3,AI consensus,correct/reviews,verdict,anomaly\n", + ); + + // Body + for (const word of words) { + const [book, chapter, verse] = word.ref.split(":"); + const modelResults = word.models.map( + (m) => `"${m.model}\n${statusMap[m.status]}"`, + ); + + const totalReviews = word.reviews.length; + + if (totalReviews === 0) continue; + + const correctReviews = word.reviews.filter((r) => r.correct).length; + const reviewsStr = `${correctReviews}/${totalReviews}`; + let verdict = ""; + if (totalReviews > 0) { + verdict = correctReviews / totalReviews >= 0.5 ? "Yes" : "No"; + } + const consensus = getConsensus(word.models.map((m) => m.status)); + let anomaly = ""; + if ( + (consensus === "Likely Incorrect" && verdict === "Yes") || + (consensus === "Likely Correct" && verdict === "No") + ) { + anomaly = "⚠️"; + } + + const row = [ + word.word, + book || "", + chapter || "", + verse || "", + ...modelResults, + consensus, + reviewsStr, + verdict, + anomaly, + ].join(","); + + await s.write(`${row}\n`); + } + }); + } catch (error: any) { + throw new HTTPException(403, { + message: `${error.code}: error fetching report: ${ + error.message || error + }`, + }); + } +}); + +app.get("/api/stats/:ietf_code/:resource_type", async (c) => { + const dbHelper = c.get("db"); + + try { + const ietf_code = c.req.param("ietf_code"); + const resource_type = c.req.param("resource_type"); + + const dbBatch = await dbHelper.getDb().query.batchesTable.findFirst({ + where: and( + eq(batchesTable.ietfCode, ietf_code), + eq(batchesTable.resourceType, resource_type), ), columns: { id: true, @@ -354,25 +478,78 @@ app.get("/api/batch/:ietf_code/:resource_type", async (c) => { }); if (!dbBatch) { - throw new HTTPException(404, { - message: "batch not found", - }); + throw new HTTPException(404, { message: "batch not found" }); } - const totalResult = await dbHelper + const consensusSubquery = dbHelper .getDb() .select({ - count: count(), + wordId: modelsTable.wordId, + consensus: sql` + CASE + WHEN bool_or(status = -1) THEN NULL + ELSE + CASE + WHEN array_agg(status) @> ARRAY[0, 0, 0]::smallint[] THEN 'Incorrect' + WHEN array_agg(status) @> ARRAY[1, 1, 1]::smallint[] THEN 'Correct' + WHEN array_agg(status) @> ARRAY[2, 2, 2]::smallint[] THEN 'Name' + ELSE 'Review Needed' + END + END + `.as("consensus"), + isProcessed: sql`NOT bool_or(status = -1)`.as("is_processed"), + }) + .from(modelsTable) + .groupBy(modelsTable.wordId) + .as("consensus_subquery"); + + const [stats] = await dbHelper + .getDb() + .select({ + correct: count(sql`CASE WHEN consensus = 'Correct' THEN 1 END`), + incorrect: count(sql`CASE WHEN consensus = 'Incorrect' THEN 1 END`), + name: count(sql`CASE WHEN consensus = 'Name' THEN 1 END`), + reviewNeeded: count( + sql`CASE WHEN consensus = 'Review Needed' THEN 1 END`, + ), + total: count(wordsTable.id), + completed: count(sql`CASE WHEN is_processed THEN 1 END`), }) .from(wordsTable) + .leftJoin(consensusSubquery, eq(wordsTable.id, consensusSubquery.wordId)) .where(eq(wordsTable.batchId, dbBatch.id)); - const total = totalResult[0].count; - const completed = await dbHelper.getCompletedWordsCount(dbBatch.id); + const userReviewCounts = await dbHelper + .getDb() + .select({ + userId: wordReviewsTable.userId, + count: count(), + }) + .from(wordReviewsTable) + .innerJoin(wordsTable, eq(wordReviewsTable.wordId, wordsTable.id)) + .where(eq(wordsTable.batchId, dbBatch.id)) + .groupBy(wordReviewsTable.userId); + + const totalReviews = userReviewCounts.reduce( + (sum, row) => sum + row.count, + 0, + ); + const averageReviews = + userReviewCounts.length > 0 ? totalReviews / userReviewCounts.length : 0; + + const statsInfo = { + ...stats, + reviewed: Math.round(averageReviews), + }; const progress: BatchProgress = { - completed: completed, - total: total, + correct: statsInfo.correct, + incorrect: statsInfo.incorrect, + name: statsInfo.name, + review_needed: statsInfo.reviewNeeded, + reviewed: statsInfo.reviewed, + completed: statsInfo.completed, + total: statsInfo.total, }; let p = 1; @@ -396,10 +573,6 @@ app.get("/api/batch/:ietf_code/:resource_type", async (c) => { status = BatchStatus.COMPLETE; } - const creator: PublicUser = { - username: dbBatch.user.username, - }; - let batchError: BatchError | null = null; if (dbBatch.error) { try { @@ -414,6 +587,10 @@ app.get("/api/batch/:ietf_code/:resource_type", async (c) => { } } + const creator: PublicUser = { + username: dbBatch.user.username, + }; + const details: BatchDetails = { status: status, error: batchError, @@ -429,86 +606,277 @@ app.get("/api/batch/:ietf_code/:resource_type", async (c) => { creator: creator, }; - const batchJson = JSON.stringify(batch); - const splitJson = splitBatchJson(batchJson); + return c.json(batch); + } catch (error: any) { + throw new HTTPException(403, { + message: `${error.code}: error fetching stats: ${error.message || error}`, + }); + } +}); - return stream(c, async (s) => { - await s.write(splitJson.left); +app.get("/api/review/:ietf_code/:resource_type", async (c) => { + const dbHelper = c.get("db"); + const db = dbHelper.getDb(); - try { - let skip = 0; - let hasMore = true; - let firstOutputItem = true; - - while (hasMore) { - const words = await dbHelper.getDb().query.wordsTable.findMany({ - where: (words, { eq, and, exists, not, lte }) => - and( - eq(words.batchId, dbBatch.id), - exists( - dbHelper - .getDb() - .select() - .from(modelsTable) - .where( - and( - eq(modelsTable.wordId, words.id), - not(lte(modelsTable.status, -1)) - ) - ) - ) - ), - columns: { - word: true, - correct: true, - }, - with: { - models: true, - }, - offset: skip, - limit: SQL_BATCH_LIMIT, - orderBy: [asc(wordsTable.id)], - }); - - if (words.length > 0) { - for (const word of words) { - if (!firstOutputItem) { - await s.write(","); - } + const HARDCODED_TOTAL_LIMIT = 370; - const modelResponses = word.models.map(({ model, status }) => ({ - model, - status, - })); - const wordResponse: WordResponse = { - word: word.word, - correct: word.correct, - results: modelResponses, - }; + try { + const ietf_code = c.req.param("ietf_code"); + const resource_type = c.req.param("resource_type"); + const payload = c.get("jwtPayload"); - await s.write(JSON.stringify(wordResponse)); - firstOutputItem = false; - } - skip += SQL_BATCH_LIMIT; - } else { - hasMore = false; - } - } - } catch (error) { - console.error(error); - } finally { - await s.write(splitJson.right); - s.close; + const user = await dbHelper.getDb().query.usersTable.findFirst({ + where: eq(usersTable.email, payload.email), + }); + + if (!user) { + throw new HTTPException(404, { message: "user not found" }); + } + + const page = parseInt(c.req.query("page") || "1", 10); + const limit = parseInt(c.req.query("limit") || "4", 10); + + const dbBatch = await dbHelper.getDb().query.batchesTable.findFirst({ + where: and( + eq(batchesTable.ietfCode, ietf_code), + eq(batchesTable.resourceType, resource_type), + ), + columns: { id: true }, + with: { + user: true, + }, + }); + + if (!dbBatch) { + throw new HTTPException(404, { message: "batch not found" }); + } + + const categorizedGoodWordsSubQuery = db + .select({ + wordId: modelsTable.wordId, + status: min(modelsTable.status).as("status"), + }) + .from(modelsTable) + .innerJoin(wordsTable, eq(modelsTable.wordId, wordsTable.id)) + .where(eq(wordsTable.batchId, dbBatch.id)) + .groupBy(modelsTable.wordId) + .having( + and( + eq(min(modelsTable.status), max(modelsTable.status)), + inArray(min(modelsTable.status), [0, 1]), + ), + ) + .as("categorized_good_words"); + + const categoryCounts = await db + .select({ + status: categorizedGoodWordsSubQuery.status, + count: count().as("count"), + }) + .from(categorizedGoodWordsSubQuery) + .groupBy(categorizedGoodWordsSubQuery.status); + + const totalGoodWords = categoryCounts.reduce( + (sum, row) => sum + row.count, + 0, + ); + + let sampledGoodWordsSubQuery; + + if (totalGoodWords <= HARDCODED_TOTAL_LIMIT) { + sampledGoodWordsSubQuery = db + .select({ wordId: categorizedGoodWordsSubQuery.wordId }) + .from(categorizedGoodWordsSubQuery) + .as("good_words"); + } else { + const limitsPerStatus = categoryCounts.map((category) => ({ + status: category.status, + limit: Math.round( + (category.count / totalGoodWords) * HARDCODED_TOTAL_LIMIT, + ), + })); + + const summedLimits = limitsPerStatus.reduce( + (sum, cat) => sum + cat.limit, + 0, + ); + if ( + summedLimits !== HARDCODED_TOTAL_LIMIT && + limitsPerStatus.length > 0 + ) { + limitsPerStatus[0].limit += HARDCODED_TOTAL_LIMIT - summedLimits; } + + const queriesPerStatus = limitsPerStatus.map((cat) => { + return db + .select({ wordId: categorizedGoodWordsSubQuery.wordId }) + .from(categorizedGoodWordsSubQuery) + .where(eq(categorizedGoodWordsSubQuery.status, cat.status)) + .limit(cat.limit); + }); + + if (queriesPerStatus.length === 0) { + sampledGoodWordsSubQuery = db + .select({ wordId: modelsTable.wordId }) + .from(modelsTable) + .where(sql`false`) + .as("good_words"); + } else if (queriesPerStatus.length === 1) { + sampledGoodWordsSubQuery = queriesPerStatus[0].as("good_words"); + } else { + const [firstQuery, secondQuery, ...restOfQueries] = queriesPerStatus; + sampledGoodWordsSubQuery = unionAll( + firstQuery, + secondQuery, + ...restOfQueries, + ).as("good_words"); + } + } + + const countResults = await db + .select({ + totalCount: count(wordsTable.id), + reviewedCount: count(wordReviewsTable.pk), + }) + .from(wordsTable) + .innerJoin( + sampledGoodWordsSubQuery, + eq(wordsTable.id, sampledGoodWordsSubQuery.wordId), + ) + .leftJoin( + wordReviewsTable, + and( + eq(wordsTable.id, wordReviewsTable.wordId), + eq(wordReviewsTable.userId, user.id), + ), + ); + + const total = countResults[0].totalCount; + const reviewed = countResults[0].reviewedCount; + + const progress: BatchProgress = { + ...emptyProgress, + reviewed: reviewed, + total: total, + }; + + let targetPage = page; + if (targetPage <= 0) { + if (reviewed >= total && total > 0) { + targetPage = Math.ceil(total / limit); + } else { + targetPage = Math.floor(reviewed / limit) + 1; + } + } + targetPage = Math.max(1, targetPage); + const offset = (targetPage - 1) * limit; + + // Fetch only the words for the requested page + const wordsData = await db + .select({ + word: wordsTable, + review: wordReviewsTable, + }) + .from(wordsTable) + .innerJoin( + sampledGoodWordsSubQuery, + eq(wordsTable.id, sampledGoodWordsSubQuery.wordId), + ) + .leftJoin( + wordReviewsTable, + and( + eq(wordsTable.id, wordReviewsTable.wordId), + eq(wordReviewsTable.userId, user.id), + ), + ) + .orderBy(asc(wordsTable.word)) + .limit(limit) + .offset(offset); + + // Map the database results to the desired response format + const output = wordsData.map((row) => { + const wordResponse: WordResponse = { + word: row.word.word, + ref: row.word.ref, + correct: row.review ? row.review.correct : null, + results: [], + }; + return wordResponse; }); + + const batchDetails: BatchDetails = { + status: BatchStatus.COMPLETE, + error: null, + progress: progress, + output, + }; + + const creator: PublicUser = { + username: dbBatch.user.username, + }; + + const batch: Batch = { + id: dbBatch.id, + ietf_code: ietf_code, + resource_type: resource_type, + details: batchDetails, + creator: creator, + }; + + return c.json(batch); } catch (error: any) { throw new HTTPException(403, { - message: `${error.code}: error fetching batch: ${error.message || error}`, + message: `${error.code}: error fetching words: ${error.message || error}`, + }); + } +}); + +app.put("/api/review/reset/:batch_id", async (c) => { + const dbHelper = c.get("db"); + + try { + const batchId = c.req.param("batch_id"); + const payload = c.get("jwtPayload"); + + const user = await dbHelper.getDb().query.usersTable.findFirst({ + where: eq(usersTable.email, payload.email), + }); + + if (!user) { + throw new HTTPException(404, { + message: "user not found", + }); + } + + if (!isAdmin(user.username, c.env)) { + throw new HTTPException(403, { message: "not allowed" }); + } + + const reset = await dbHelper + .getDb() + .delete(wordReviewsTable) + .where( + inArray( + wordReviewsTable.wordId, + dbHelper + .getDb() + .select({ id: wordsTable.id }) + .from(wordsTable) + .where(eq(wordsTable.batchId, batchId)), + ), + ); + + return c.json(reset.length > 0); + } catch (error: any) { + throw new HTTPException(403, { + message: `${error.code}: error resetting review: ${ + error.message || error + }`, }); } }); -app.delete("/api/batch/cancel/:batch_id", async (c) => { +app.delete("/api/batch/pause/:batch_id", async (c) => { const dbHelper = c.get("db"); try { @@ -539,10 +907,24 @@ app.delete("/api/batch/cancel/:batch_id", async (c) => { .where(eq(batchesTable.id, batch_id)) .returning(); + if (cancelled.length > 0) { + // also delete incomplete models + const badWordIdsSubQuery = dbHelper + .getDb() + .selectDistinct({ wordId: modelsTable.wordId }) + .from(modelsTable) + .where(eq(modelsTable.status, -1)); + + await dbHelper + .getDb() + .delete(modelsTable) + .where(inArray(modelsTable.wordId, badWordIdsSubQuery)); + } + return c.json(cancelled.length > 0); } catch (error: any) { throw new HTTPException(403, { - message: `${error.code}: error deleting batch: ${error.message || error}`, + message: `${error.code}: error pausing batch: ${error.message || error}`, }); } }); @@ -606,10 +988,7 @@ app.get("/api/batch/recent", async (c) => { .innerJoin(modelsTable, eq(wordsTable.id, modelsTable.wordId)) .innerJoin(usersTable, eq(batchesTable.userId, usersTable.id)); - const progress: BatchProgress = { - completed: 0, - total: 0, - }; + const progress = emptyProgress; const details: BatchDetails = { status: BatchStatus.COMPLETE, @@ -634,20 +1013,13 @@ app.get("/api/batch/recent", async (c) => { } }); -app.post("/api/word", async (c) => { +app.post("/api/words", async (c) => { const dbHelper = c.get("db"); - const json = await c.req.json(); - - const batch_id = json.batch_id || null; - const word = json.word || null; - const correct = json.correct; const payload = c.get("jwtPayload"); - try { - if (!batch_id || !word) { - throw new HTTPException(403, { message: "invalid parameters" }); - } + const request: WordsParams = await c.req.json(); + try { const user = await dbHelper.getDb().query.usersTable.findFirst({ where: eq(usersTable.email, payload.email), }); @@ -656,13 +1028,40 @@ app.post("/api/word", async (c) => { throw new HTTPException(404, { message: "user not found" }); } - await dbHelper + const wordStrings = request.words.map((w) => w.word); + const foundWords = await dbHelper .getDb() - .update(wordsTable) - .set({ - correct: correct, + .select({ + id: wordsTable.id, + word: wordsTable.word, }) - .where(and(eq(wordsTable.batchId, batch_id), eq(wordsTable.word, word))); + .from(wordsTable) + .where( + and( + eq(wordsTable.batchId, request.batchId), + inArray(wordsTable.word, wordStrings), + ), + ); + + const wordMap = new Map(foundWords.map((row) => [row.word, row.id])); + const reviewsToUpsert = request.words + .filter((w) => wordMap.has(w.word)) + .map((w) => ({ + wordId: wordMap.get(w.word)!, + userId: user.id, + correct: w.correct, + })); + + if (reviewsToUpsert.length > 0) { + await dbHelper + .getDb() + .insert(wordReviewsTable) + .values(reviewsToUpsert) + .onConflictDoUpdate({ + target: [wordReviewsTable.wordId, wordReviewsTable.userId], + set: { correct: sql`excluded.correct` }, + }); + } return c.json(true); } catch (error: any) { @@ -672,13 +1071,121 @@ app.post("/api/word", async (c) => { } }); +app.get("*", async (c) => { + const response = await c.env.ASSETS.fetch(c.req.raw); + if (response.status === 404) { + const indexRequest = new Request(new URL("/index.html", c.req.url), { + method: "GET", + headers: c.req.raw.headers, + }); + return c.env.ASSETS.fetch(indexRequest); + } + return response; +}); + export default { fetch: app.fetch, async scheduled( controller: ScheduledController, env: CloudflareBindings, - ctx: ExecutionContext + ctx: ExecutionContext, ) { + const validateAndMapResults = ( + words: WordEntity[], + chatResponse: ChatResponse[], + retries: number, + ): ChatResponse[] => { + const usedIndices = new Set(); + let hasLoggedContext = false; + + // Helper to remove accents and lower case: "Bånana" -> "banana" + const normalize = (str: string) => + str + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .trim(); + + // Helper: Lazy Logger for the "Big Picture" + const logContextOnce = () => { + if (hasLoggedContext) return; + hasLoggedContext = true; + + const refListStr = words.map((w) => w.word).join(", "); + const chatListStr = chatResponse.map((w) => w.word).join(", "); + + console.warn( + `\n🔍 MISMATCH DETECTED - DEBUG CONTEXT\n` + + `--------------------------------------------------\n` + + `Sizes: Ref (${words.length}) vs Chat (${chatResponse.length})\n` + + `Ref List: [${refListStr}]\n` + + `Chat List: [${chatListStr}]\n` + + `--------------------------------------------------`, + ); + }; + + return words.map((refItem) => { + const targetStrict = refItem.word; + const targetLoose = normalize(refItem.word); + + // Try to find the exact word anywhere in the list + let matchIndex = -1; + let matchType: "strict" | "loose" | "missing" = "missing"; + + matchIndex = chatResponse.findIndex( + (chatItem, index) => + chatItem.word === targetStrict && !usedIndices.has(index), + ); + + if (matchIndex !== -1) { + matchType = "strict"; + } + + // If strict failed, try matching normalized strings (accents, case) + if (matchIndex === -1) { + matchIndex = chatResponse.findIndex( + (chatItem, index) => + normalize(chatItem.word) === targetLoose && + !usedIndices.has(index), + ); + if (matchIndex !== -1) matchType = "loose"; + } + + if (matchIndex !== -1) { + usedIndices.add(matchIndex); // Claim this index so it's not used again + const foundItem = chatResponse[matchIndex]; + + // LOGGING: Only log if it wasn't a perfect strict match + if (matchType !== "strict") { + logContextOnce(); // Print the lists first if we haven't yet + + const msg = `⚠️ Loose Match for "${refItem.word}" -> Found "${foundItem.word}"`; + console.warn(msg); + } + + return { + ...refItem, + status: foundItem.status, + }; + } + + // If completely MISSING + logContextOnce(); // Print context + console.error(`❌ Missing Word: "${refItem.word}"`); + + if (retries >= 3) { + console.error( + `❌ Giving up on word "${refItem.word}" after ${retries} retries. Marking as failed.`, + ); + } + + return { + ...refItem, + status: retries < 3 ? -1 : 0, + }; + }); + }; + try { const client = new AiClient(env); const dbHelper = new DbHelper(env); @@ -703,10 +1210,10 @@ export default { .where( and( eq(modelsTable.wordId, words.id), - eq(modelsTable.status, -1) - ) - ) - ) + eq(modelsTable.status, -1), + ), + ), + ), ), with: { models: true, @@ -718,6 +1225,7 @@ export default { interface TmpModel { model: string; words: { word: string; status: number }[]; + retries: number; } const models = words.reduce((acc: TmpModel[], wordObj) => { @@ -737,6 +1245,7 @@ export default { status: modelObj.status, }, ], + retries: modelObj.retries, }); } }); @@ -744,9 +1253,7 @@ export default { }, []); const modelsResults: ModelResult[] = []; - let prompt = `Language: ${batch.language}. Words: ${words - .map((w) => w.word) - .join(", ")}`; + let wordsPrompt = words.map((w) => w.word).join(", "); for (const model of models) { try { @@ -760,23 +1267,37 @@ export default { const modelResult: ModelResult = { model: model.model, results: results, + retries: model.retries + 1, }; modelsResults.push(modelResult); } else { - const results = await client.chat(model.model, prompt); - if (!isChatError(results)) { + const chatResponse = await client.chat( + model.model, + batch.language, + wordsPrompt, + ); + + if (!isChatError(chatResponse)) { + // Sometimes the AI might change the words, so we re-map them here from the original array + const results = validateAndMapResults( + words, + chatResponse, + model.retries, + ); + const modelResult: ModelResult = { model: model.model, results: results, + retries: model.retries + 1, }; modelsResults.push(modelResult); } else { - errorDetails = results; + errorDetails = chatResponse; } } } catch (error: any) { errorDetails = { - prompt, + prompt: wordsPrompt, message: error.message || error, model: model.model, response: null, @@ -784,15 +1305,16 @@ export default { } } - const updateError = await dbHelper.updateModelResults( - words.map((w) => w.word), - batchId, - modelsResults - ); + if (modelsResults.length > 0) { + const updateError = await dbHelper.updateModelResults( + batchId, + modelsResults, + ); - if (updateError) { - updateError.prompt = prompt; - errorDetails = updateError; + if (updateError) { + updateError.prompt = wordsPrompt; + errorDetails = updateError; + } } } @@ -827,7 +1349,7 @@ export default { .where(eq(batchesTable.id, batchId)); } } catch (error) { - console.error(error); + console.error("cron error:", error); } }, }; diff --git a/api/src/types.ts b/api/src/types.ts index a967b6e..bfe0ac1 100644 --- a/api/src/types.ts +++ b/api/src/types.ts @@ -1,11 +1,11 @@ export type WordsParams = { batchId: string; - words: WordsRequest[]; + words: WordRequest[]; }; -export type WordsRequest = { - prompt: string; - models: any[]; +export type WordRequest = { + word: string; + correct: boolean; }; export type BatchRequest = { @@ -24,6 +24,11 @@ export type Batch = { }; export type BatchProgress = { + correct: number; + incorrect: number; + name: number; + review_needed: number; + reviewed: number; completed: number; total: number; }; @@ -37,6 +42,7 @@ export type BatchDetails = { export type WordResponse = { word: string; + ref: string; correct: boolean | null; results: ModelResponse[]; }; @@ -72,9 +78,15 @@ export enum BatchStatus { export type ModelResult = { model: string; results: ChatResponse[]; + retries: number; }; export type SplitBatchJson = { left: string; right: string; }; + +export type WordData = { + word: string; + ref: string; +}; diff --git a/api/src/utils.ts b/api/src/utils.ts index 06b44aa..a10ff8c 100644 --- a/api/src/utils.ts +++ b/api/src/utils.ts @@ -13,31 +13,6 @@ export const chunkArray = (array: any[], size: number) => { return arr; }; -export const splitBatchJson = (json: string) => { - const searchString = '"output":[]'; - const outputStart = json.indexOf(searchString); - const outputEnd = outputStart + searchString.length; - - const output = json.substring(outputStart, outputEnd); - - const bracketStart = output.indexOf("["); - const bracketEnd = output.indexOf("]"); - const bracketSize = bracketEnd - bracketStart; - - const finalStart = outputStart + bracketStart + 1; - const finalEnd = outputEnd - bracketSize; - - const leftPart = json.substring(0, finalStart); - const rightPart = json.substring(finalEnd, json.length); - - const splitJson: SplitBatchJson = { - left: leftPart, - right: rightPart, - }; - - return splitJson; -}; - export const isChatError = (obj: any): obj is BatchError => { return ( obj && diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 795ec5c..69544e6 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -32,7 +32,7 @@ kotlin { @OptIn(ExperimentalWasmDsl::class) wasmJs { - moduleName = "composeApp" + outputModuleName = "composeApp" browser { val rootDirPath = project.rootDir.path val projectDirPath = project.projectDir.path @@ -47,11 +47,11 @@ kotlin { } } } - browser() // compilerOptions { // freeCompilerArgs.add("-Xwasm-debugger-custom-formatters") // freeCompilerArgs.add("-Xwasm-attach-js-exception") // freeCompilerArgs.add("-Xwasm-use-new-exception-proposal") +// freeCompilerArgs.add("-Xwasm-generate-dwarf") // } binaries.executable() } @@ -65,7 +65,6 @@ kotlin { dependsOn(commonMain) dependencies { implementation(libs.usfmtools) - implementation(libs.kotlin.document.store.leveldb) } } androidMain.dependencies { @@ -90,6 +89,7 @@ kotlin { implementation(compose.components.uiToolingPreview) implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.kotlinx.serialization.json) implementation(libs.apollo.runtime) @@ -107,12 +107,14 @@ kotlin { implementation(libs.voyager.koin) implementation(libs.compose.remember.setting) - implementation(libs.filekit.core) - implementation(libs.filekit.compose) + implementation(libs.filekit.dialogs.core) + implementation(libs.filekit.dialogs.compose) implementation(libs.jwt.kt) - implementation(libs.kotlin.document.store.core) implementation(libs.kotlinx.datetime) + implementation(libs.sketch.compose) + implementation(libs.sketch.compose.gif) + implementation(libs.sketch.compose.resources) } } desktopMain.dependencies { @@ -122,7 +124,6 @@ kotlin { } wasmJsMain.dependencies { implementation(npm("usfmtools", "1.0.6")) - implementation(libs.kotlin.document.store.browser) } androidMain.dependsOn(javaMain) @@ -225,6 +226,7 @@ tasks.register("copyWebClient", Copy::class) { tasks.register("buildWebDistribution") { dependsOn("clean") + dependsOn(":kotlinWasmUpgradeYarnLock") dependsOn("generateBuildConfig") dependsOn("wasmJsBrowserDistribution") dependsOn("copyWebClient") diff --git a/composeApp/src/androidMain/kotlin/org/bibletranslationtools/wat/MainActivity.kt b/composeApp/src/androidMain/kotlin/org/bibletranslationtools/wat/MainActivity.kt index 8168550..ff40121 100644 --- a/composeApp/src/androidMain/kotlin/org/bibletranslationtools/wat/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/org/bibletranslationtools/wat/MainActivity.kt @@ -3,7 +3,8 @@ package org.bibletranslationtools.wat import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import io.github.vinceglb.filekit.core.FileKit +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.dialogs.init class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { diff --git a/composeApp/src/androidMain/kotlin/org/bibletranslationtools/wat/platform/FileCache.android.kt b/composeApp/src/androidMain/kotlin/org/bibletranslationtools/wat/platform/FileCache.android.kt new file mode 100644 index 0000000..159a8a7 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/bibletranslationtools/wat/platform/FileCache.android.kt @@ -0,0 +1,37 @@ +package org.bibletranslationtools.wat.platform + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.koin.mp.KoinPlatform.getKoin +import java.io.File +import java.security.MessageDigest + +actual class FileCache(private val context: Context) { + private val cacheDir: File by lazy { context.cacheDir } + + private fun urlToFileName(url: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val hashBytes = digest.digest(url.toByteArray(Charsets.UTF_8)) + return hashBytes.joinToString("") { "%02x".format(it) } + } + + actual suspend fun get(url: String): ByteArray? = withContext(Dispatchers.IO) { + val file = File(cacheDir, urlToFileName(url)) + if (file.exists()) { + file.readBytes() + } else { + null + } + } + + actual suspend fun put(url: String, data: ByteArray) = withContext(Dispatchers.IO) { + val file = File(cacheDir, urlToFileName(url)) + file.writeBytes(data) + } +} + +actual fun createFileCache(): FileCache { + val context: Context = getKoin().get() + return FileCache(context) +} diff --git a/composeApp/src/androidMain/kotlin/org/bibletranslationtools/wat/platform/Platform.android.kt b/composeApp/src/androidMain/kotlin/org/bibletranslationtools/wat/platform/Platform.android.kt index f5a2e79..ec4082b 100644 --- a/composeApp/src/androidMain/kotlin/org/bibletranslationtools/wat/platform/Platform.android.kt +++ b/composeApp/src/androidMain/kotlin/org/bibletranslationtools/wat/platform/Platform.android.kt @@ -2,8 +2,6 @@ package org.bibletranslationtools.wat.platform import android.content.Context import android.os.LocaleList -import io.github.mxaln.kotlin.document.store.core.DataStore -import io.github.mxaln.kotlin.document.store.stores.leveldb.android.openLevelDBStore import io.ktor.client.engine.HttpClientEngine import io.ktor.client.engine.android.Android import org.koin.mp.KoinPlatform.getKoin @@ -16,12 +14,6 @@ actual val appDirPath: String ?: throw IllegalArgumentException("External files dir not found") } -actual val dbStore: DataStore - get() { - val context: Context = getKoin().get() - return context.openLevelDBStore() - } - actual val httpClientEngine: HttpClientEngine get() { return Android.create() @@ -29,7 +21,7 @@ actual val httpClientEngine: HttpClientEngine actual fun applyLocale(iso: String) { val context: Context = getKoin().get() - val locale = Locale(iso) + val locale = Locale.forLanguageTag(iso) Locale.setDefault(locale) val config = context.resources.configuration config.setLocales(LocaleList(locale)) diff --git a/composeApp/src/commonMain/composeResources/drawable/admin.xml b/composeApp/src/commonMain/composeResources/drawable/admin.xml new file mode 100644 index 0000000..560d1b6 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/admin.xml @@ -0,0 +1,10 @@ + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/flag.xml b/composeApp/src/commonMain/composeResources/drawable/flag.xml new file mode 100644 index 0000000..2da7fa0 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/flag.xml @@ -0,0 +1,13 @@ + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/flag_filled.xml b/composeApp/src/commonMain/composeResources/drawable/flag_filled.xml new file mode 100644 index 0000000..0dfc575 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/flag_filled.xml @@ -0,0 +1,17 @@ + + + + diff --git a/composeApp/src/commonMain/composeResources/files/flag_tutor.gif b/composeApp/src/commonMain/composeResources/files/flag_tutor.gif new file mode 100644 index 0000000..d9cd55e Binary files /dev/null and b/composeApp/src/commonMain/composeResources/files/flag_tutor.gif differ diff --git a/composeApp/src/commonMain/composeResources/font/noto_sans.ttf b/composeApp/src/commonMain/composeResources/font/noto_sans.ttf deleted file mode 100644 index 9530d84..0000000 Binary files a/composeApp/src/commonMain/composeResources/font/noto_sans.ttf and /dev/null differ diff --git a/composeApp/src/commonMain/composeResources/font/noto_sans_arabic.ttf b/composeApp/src/commonMain/composeResources/font/noto_sans_arabic.ttf deleted file mode 100644 index fc848dc..0000000 Binary files a/composeApp/src/commonMain/composeResources/font/noto_sans_arabic.ttf and /dev/null differ diff --git a/composeApp/src/commonMain/composeResources/font/noto_sans_arabic_bold.ttf b/composeApp/src/commonMain/composeResources/font/noto_sans_arabic_bold.ttf new file mode 100644 index 0000000..ab5b96f Binary files /dev/null and b/composeApp/src/commonMain/composeResources/font/noto_sans_arabic_bold.ttf differ diff --git a/composeApp/src/commonMain/composeResources/font/noto_sans_arabic_regular.ttf b/composeApp/src/commonMain/composeResources/font/noto_sans_arabic_regular.ttf new file mode 100644 index 0000000..08a5b52 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/font/noto_sans_arabic_regular.ttf differ diff --git a/composeApp/src/commonMain/composeResources/font/noto_sans_bold.ttf b/composeApp/src/commonMain/composeResources/font/noto_sans_bold.ttf new file mode 100644 index 0000000..506f7d8 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/font/noto_sans_bold.ttf differ diff --git a/composeApp/src/commonMain/composeResources/font/noto_sans_chinese_simplified_bold.ttf b/composeApp/src/commonMain/composeResources/font/noto_sans_chinese_simplified_bold.ttf new file mode 100644 index 0000000..1edc546 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/font/noto_sans_chinese_simplified_bold.ttf differ diff --git a/composeApp/src/commonMain/composeResources/font/noto_sans_chinese_simplified_regular.ttf b/composeApp/src/commonMain/composeResources/font/noto_sans_chinese_simplified_regular.ttf new file mode 100644 index 0000000..176f113 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/font/noto_sans_chinese_simplified_regular.ttf differ diff --git a/composeApp/src/commonMain/composeResources/font/noto_sans_korean_bold.ttf b/composeApp/src/commonMain/composeResources/font/noto_sans_korean_bold.ttf new file mode 100644 index 0000000..14eabfe Binary files /dev/null and b/composeApp/src/commonMain/composeResources/font/noto_sans_korean_bold.ttf differ diff --git a/composeApp/src/commonMain/composeResources/font/noto_sans_korean_regular.ttf b/composeApp/src/commonMain/composeResources/font/noto_sans_korean_regular.ttf new file mode 100644 index 0000000..dee35df Binary files /dev/null and b/composeApp/src/commonMain/composeResources/font/noto_sans_korean_regular.ttf differ diff --git a/composeApp/src/commonMain/composeResources/font/noto_sans_malayalam_bold.ttf b/composeApp/src/commonMain/composeResources/font/noto_sans_malayalam_bold.ttf new file mode 100644 index 0000000..673a6b7 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/font/noto_sans_malayalam_bold.ttf differ diff --git a/composeApp/src/commonMain/composeResources/font/noto_sans_malayalam_regular.ttf b/composeApp/src/commonMain/composeResources/font/noto_sans_malayalam_regular.ttf new file mode 100644 index 0000000..dc60f0f Binary files /dev/null and b/composeApp/src/commonMain/composeResources/font/noto_sans_malayalam_regular.ttf differ diff --git a/composeApp/src/commonMain/composeResources/font/noto_sans_regular.ttf b/composeApp/src/commonMain/composeResources/font/noto_sans_regular.ttf new file mode 100644 index 0000000..4bac02f Binary files /dev/null and b/composeApp/src/commonMain/composeResources/font/noto_sans_regular.ttf differ diff --git a/composeApp/src/commonMain/composeResources/font/noto_serif_tibetan_bold.ttf b/composeApp/src/commonMain/composeResources/font/noto_serif_tibetan_bold.ttf new file mode 100644 index 0000000..943a635 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/font/noto_serif_tibetan_bold.ttf differ diff --git a/composeApp/src/commonMain/composeResources/font/noto_serif_tibetan_regular.ttf b/composeApp/src/commonMain/composeResources/font/noto_serif_tibetan_regular.ttf new file mode 100644 index 0000000..9422ef2 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/font/noto_serif_tibetan_regular.ttf differ diff --git a/composeApp/src/commonMain/composeResources/values-ru/strings.xml b/composeApp/src/commonMain/composeResources/values-ru/strings.xml index 66349c5..d0de2af 100644 --- a/composeApp/src/commonMain/composeResources/values-ru/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ru/strings.xml @@ -9,6 +9,7 @@ Произошла неизвестная ошибка: %1$s OK Загрузка USFM... + Ошибка загрузки файла USFM. Парсинг USFM... Получение материнских языков... Получение типов ресурсов... @@ -28,10 +29,11 @@ Поготовка к анализу... Модели Нет ответа - Отменить получение результатов + Приостановить получение результатов Удалить результаты Сохранить отчет (.csv) - Отчет сохранен. + Создание отчета... + Вы успешно сохранили отчет. Всего обработано Возможно правильно Возможно ошибка @@ -41,16 +43,16 @@ Войти с помощью WACS Идет авторизация... Пожалуйста, подождите. Выйти + %1$s (Выйти) Срок действия кода доступа истек. Пожалуйста, войдите снова. - Не удалось отменить получение результатов. Попробуйте еще раз. - Получение результатов успешно отменено. + Не удалось приостановить получение результатов. Попробуйте еще раз. + Получение результатов успешно приостановлено. Не удалось удалить результаты. Попробуйте еще раз. Результаты успешно удалены. Получить результаты Сортировать слова Создание запроса на результаты... Все результаты получены. - Шрифт Верно ли это слово? Является ли это слово именем? Ссылка на Писание @@ -58,7 +60,7 @@ Неверно Да Нет - Отменяем получение результатов... + Приостанавливаем получение результатов... Удаление результатов... Обновление слова... Неверный идентификатор. @@ -75,4 +77,26 @@ Запрос: Модель: Ответ: + Домой + Please identify bolded words from the list below that are misspelled by selecting their corresponding flags (%1$s) + Назад + Сохранить + Сохранить и Далее + Страница %1$d/%2$d + Все изменения сохранены. Неотмеченные слова были отмечены как правильные. + Загрузка... Пожалуйста подождите. + Админ + Получение информации о языке + Получение информации о проекте + Инфо + Успех + Ошибка + Показать больше + Показать меньше + Сбросить прогресс проверки + Производится сброс прогресса проверки + Сброс прогресса проверки успешно произведен. + Успешно завершено! + Вы успешно завершили проверку неправильных слов. + Вернуться домой \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 14a6a39..39660f4 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -9,6 +9,7 @@ Unknown error occurred: %1$s OK Downloading USFM... + Failed to download USFM file. Parsing USFM... Fetching Heart Languages... Fetching Resource Types... @@ -28,10 +29,11 @@ Preparing for analysis... Models Did not respond - Cancel Batch + Pause Results Delete Results Save Report (.csv) - Report saved. + Generating report... + You have successfully saved report. Words Processed Likely Correct Likely Incorrect @@ -41,16 +43,16 @@ Login with WACS Authorizing... Please wait. Logout + %1$s (Sign Out) Access token expired. Please login again. - Could not cancel batch. Please try again. - Batch successfully cancelled. + Could not pause result retrieval. Please try again. + Result processing has been paused successfully. Could not delete results. Please try again. Results successfully deleted. Get Results Sort words Creating request for results... All results received. - Font Is this word correct? Is this word a name? Scripture Reference @@ -58,7 +60,7 @@ Incorrect Yes No - Cancelling batch... + Pausing batch... Deleting results... Updating word... Invalid ID. @@ -75,4 +77,26 @@ Prompt: Model: Response: + Home + Please identify bolded words from the list below that are misspelled by selecting their corresponding flags (%1$s) + Back + Save + Save & Next + Page %1$d/%2$d + All changes saved. Unflagged words have been marked as correctly spelled. + Loading... Please wait. + Admin + Receiving language info + Receiving batch info + Info + Success + Error + View more + View less + Reset Review Progress + Resetting review progress... + Review progress successfully reset. + Successfully Completed! + You have successfully completed checking for misspelled words. + Return Home \ No newline at end of file diff --git a/composeApp/src/commonMain/graphql/org/bibletranslationtools/wat/GetLanguageInfo.graphql b/composeApp/src/commonMain/graphql/org/bibletranslationtools/wat/GetLanguageInfo.graphql new file mode 100644 index 0000000..49cae2d --- /dev/null +++ b/composeApp/src/commonMain/graphql/org/bibletranslationtools/wat/GetLanguageInfo.graphql @@ -0,0 +1,14 @@ +query GetLanguageInfo($ietfCode: String!) { + language( + where: { + ietf_code: { + _eq: $ietfCode + } + } + ) { + ietf_code, + english_name, + national_name, + direction + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/App.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/App.kt index e0e376f..324ab2e 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/App.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/App.kt @@ -3,23 +3,22 @@ package org.bibletranslationtools.wat import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.transitions.SlideTransition import dev.burnoo.compose.remembersetting.rememberStringSetting -import org.bibletranslationtools.wat.domain.Fonts import org.bibletranslationtools.wat.domain.Locales import org.bibletranslationtools.wat.domain.Settings import org.bibletranslationtools.wat.domain.Theme +import org.bibletranslationtools.wat.navigation.UrlManager import org.bibletranslationtools.wat.platform.applyLocale import org.bibletranslationtools.wat.ui.LoginScreen import org.bibletranslationtools.wat.ui.theme.DarkColorScheme import org.bibletranslationtools.wat.ui.theme.LightColorScheme import org.bibletranslationtools.wat.ui.theme.MainAppTheme -import org.bibletranslationtools.wat.ui.theme.NotoSansArabicFontFamily -import org.bibletranslationtools.wat.ui.theme.NotoSansFontFamily @Composable -fun App() { +fun App(initialPath: String? = null) { val theme by rememberStringSetting(Settings.THEME.name, Theme.SYSTEM.name) val colorScheme = when { theme == Theme.LIGHT.name -> LightColorScheme @@ -31,14 +30,13 @@ fun App() { val locale by rememberStringSetting(Settings.LOCALE.name, Locales.EN.name) applyLocale(locale.lowercase()) - val font by rememberStringSetting(Settings.FONT.name, Fonts.NOTO_SANS.name) - val fontFamily = when (font) { - Fonts.NOTO_SANS_ARABIC.name -> NotoSansArabicFontFamily() - else -> NotoSansFontFamily() + val initialScreen = remember(initialPath) { + LoginScreen(initialPath) } - MainAppTheme(colorScheme, fontFamily) { - Navigator(LoginScreen()) { navigator -> + MainAppTheme(colorScheme) { + Navigator(initialScreen) { navigator -> + UrlManager.Init(navigator) SlideTransition(navigator) } } diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/Extensions.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/Extensions.kt index 32b535d..9f6c37c 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/Extensions.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/Extensions.kt @@ -1,6 +1,7 @@ package org.bibletranslationtools.wat import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.number import kotlinx.io.Buffer import kotlinx.io.Source import kotlinx.io.writeString @@ -21,8 +22,8 @@ fun String.formatWith(values: Map): String { fun LocalDateTime.format(): String { val year = year.toString().padStart(4, '0') - val month = monthNumber.toString().padStart(2, '0') - val day = dayOfMonth.toString().padStart(2, '0') + val month = month.number.toString().padStart(2, '0') + val day = day.toString().padStart(2, '0') val hour = hour.toString().padStart(2, '0') val minute = minute.toString().padStart(2, '0') val second = second.toString().padStart(2, '0') diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/data/Alert.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/data/Alert.kt deleted file mode 100644 index dd7dc63..0000000 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/data/Alert.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.bibletranslationtools.wat.data - -data class Alert( - val message: String, - val onClosed: () -> Unit = {} -) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/data/ReviewWord.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/data/ReviewWord.kt new file mode 100644 index 0000000..d61e491 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/data/ReviewWord.kt @@ -0,0 +1,7 @@ +package org.bibletranslationtools.wat.data + +data class ReviewWord( + val word: String, + val ref: Verse, + val correct: Boolean? = null +) diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/data/ToastInfo.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/data/ToastInfo.kt new file mode 100644 index 0000000..8e98359 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/data/ToastInfo.kt @@ -0,0 +1,47 @@ +package org.bibletranslationtools.wat.data + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import org.jetbrains.compose.resources.StringResource +import wordanalysistool.composeapp.generated.resources.Res +import wordanalysistool.composeapp.generated.resources.error_toast +import wordanalysistool.composeapp.generated.resources.info_toast +import wordanalysistool.composeapp.generated.resources.success_toast + +sealed class ToastType( + val title: StringResource, + val icon: ImageVector, + val mainColor: @Composable () -> Color, + val backgroundColor: @Composable () -> Color +) { + object Info : ToastType( + title = Res.string.info_toast, + icon = Icons.Default.Info, + mainColor = { MaterialTheme.colorScheme.primary }, + backgroundColor = { MaterialTheme.colorScheme.primaryContainer } + ) + object Success : ToastType( + title = Res.string.success_toast, + icon = Icons.Default.CheckCircle, + mainColor = { MaterialTheme.colorScheme.tertiary }, + backgroundColor = { MaterialTheme.colorScheme.tertiaryContainer } + ) + object Error : ToastType( + title = Res.string.error_toast, + icon = Icons.Default.Cancel, + mainColor = { MaterialTheme.colorScheme.error }, + backgroundColor = { MaterialTheme.colorScheme.errorContainer } + ) +} + +data class ToastInfo( + val type: ToastType, + val message: String, + val onClose: () -> Unit +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/data/Verse.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/data/Verse.kt index 41c70ca..472e9a3 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/data/Verse.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/data/Verse.kt @@ -1,9 +1,27 @@ package org.bibletranslationtools.wat.data +import kotlinx.serialization.Serializable + +typealias VerseRef = Map +typealias MutableVerseRef = MutableMap + +@Serializable data class Verse( - val number: Int, - val text: String, - val bookSlug: String, - val bookName: String, - val chapter: Int -) + val book: String, + val chapter: Int, + val verse: String, + val text: String +) { + override fun toString(): String { + return "$book:$chapter:$verse" + } +} + +fun String.toVerse(): Verse { + val parts = this.split(":") + return Verse( + book = parts[0], + chapter = parts[1].toInt(), + verse = parts[2], + text = "Verse text not found.") +} diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/di/Modules.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/di/Modules.kt index 0ccb807..7ca686d 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/di/Modules.kt @@ -1,8 +1,6 @@ package org.bibletranslationtools.wat.di -import io.github.mxaln.kotlin.document.store.core.KotlinDocumentStore -import org.bibletranslationtools.wat.data.LanguageInfo -import org.bibletranslationtools.wat.data.Verse +import org.bibletranslationtools.wat.data.VerseRef import org.bibletranslationtools.wat.domain.BielGraphQlApi import org.bibletranslationtools.wat.domain.DownloadUsfm import org.bibletranslationtools.wat.domain.User @@ -10,34 +8,56 @@ import org.bibletranslationtools.wat.domain.UsfmBookSource import org.bibletranslationtools.wat.domain.UsfmBookSourceImpl import org.bibletranslationtools.wat.domain.WatApi import org.bibletranslationtools.wat.domain.WatApiImpl -import org.bibletranslationtools.wat.domain.WordDataSource -import org.bibletranslationtools.wat.domain.WordDataSourceImpl import org.bibletranslationtools.wat.domain.createAiHttpClient import org.bibletranslationtools.wat.domain.createSimpleHttpClient -import org.bibletranslationtools.wat.platform.dbStore +import org.bibletranslationtools.wat.platform.createFileCache import org.bibletranslationtools.wat.platform.httpClientEngine import org.bibletranslationtools.wat.ui.AnalyzeViewModel import org.bibletranslationtools.wat.ui.HomeViewModel import org.bibletranslationtools.wat.ui.LoginViewModel +import org.bibletranslationtools.wat.ui.ReviewViewModel import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.singleOf import org.koin.dsl.bind import org.koin.dsl.module val sharedModule = module { - single { KotlinDocumentStore(dbStore) } - singleOf(::WordDataSourceImpl).bind() - singleOf(::BielGraphQlApi) single { DownloadUsfm(createSimpleHttpClient(httpClientEngine)) } factory { WatApiImpl(createAiHttpClient(httpClientEngine)) }.bind() + single { createFileCache() } factoryOf(::UsfmBookSourceImpl).bind() // view models factoryOf(::LoginViewModel) - factory { (user: User) -> HomeViewModel(get(), get(), get(), get(), user) } - factory { (language: LanguageInfo, resourceType: String, verses: List, user: User) -> - AnalyzeViewModel(language, resourceType, verses, user, get()) + factory { (user: User) -> + HomeViewModel( + user = user, + bielGraphQlApi = get(), + watApi = get() + ) + } + factory { (ietfCode: String, resourceType: String, user: User, batchId: String?) -> + ReviewViewModel( + ietfCode = ietfCode, + resourceType = resourceType, + user = user, + batchId = batchId, + watApi = get(), + bielGraphQlApi = get(), + downloadUsfm = get(), + usfmBookSource = get() + ) + } + factory { (ietfCode: String, resourceType: String, verses: VerseRef, user: User) -> + AnalyzeViewModel( + ietfCode = ietfCode, + resourceType = resourceType, + verses = verses, + user = user, + watApi = get(), + bielGraphQlApi = get() + ) } } diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/domain/BielGraphQlApi.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/domain/BielGraphQlApi.kt index 14e7487..b5fab93 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/domain/BielGraphQlApi.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/domain/BielGraphQlApi.kt @@ -4,6 +4,7 @@ import com.apollographql.apollo.ApolloClient import org.bibletranslationtools.wat.GetBooksForTranslationQuery import org.bibletranslationtools.wat.GetGatewayLanguagesQuery import org.bibletranslationtools.wat.GetHeartLanguagesQuery +import org.bibletranslationtools.wat.GetLanguageInfoQuery import org.bibletranslationtools.wat.GetUsfmForHeartLanguageQuery import org.bibletranslationtools.wat.data.ContentInfo import org.bibletranslationtools.wat.data.Direction @@ -97,4 +98,18 @@ class BielGraphQlApi { return usfmContent } + + suspend fun getLanguageInfo(ietfCode: String): LanguageInfo? { + val response = apolloClient.query(GetLanguageInfoQuery(ietfCode)).execute() + return response.data?.let { data -> + data.language.firstOrNull()?.let { + LanguageInfo( + ietfCode = it.ietf_code, + name = it.national_name, + angName = it.english_name, + direction = Direction.of(it.direction) + ) + } + } + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/domain/DownloadUsfm.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/domain/DownloadUsfm.kt index a355d72..202e6bb 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/domain/DownloadUsfm.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/domain/DownloadUsfm.kt @@ -11,6 +11,7 @@ import wordanalysistool.composeapp.generated.resources.Res import wordanalysistool.composeapp.generated.resources.unknown_error class DownloadUsfm(private val httpClient: HttpClient) { + suspend operator fun invoke(url: String): ApiResult { val response = get(httpClient, url) @@ -22,7 +23,11 @@ class DownloadUsfm(private val httpClient: HttpClient) { ApiResult.Error(response.error) } else -> ApiResult.Error( - NetworkError(ErrorType.Unknown, -1, getString(Res.string.unknown_error)) + NetworkError( + ErrorType.Unknown, + -1, + getString(Res.string.unknown_error) + ) ) } } diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/domain/Settings.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/domain/Settings.kt index d3a07bb..369cc87 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/domain/Settings.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/domain/Settings.kt @@ -6,9 +6,7 @@ enum class Settings { THEME, LOCALE, APOSTROPHE_IS_SEPARATOR, - SORT_WORDS, - ACCESS_TOKEN, - FONT + ACCESS_TOKEN } enum class Theme { @@ -18,26 +16,15 @@ enum class Theme { } enum class Model(val value: String) { - GPT_4_1("gpt-4.1"), - GPT_4_1_MINI("gpt-4.1-mini"), - GPT_4_1_NANO("gpt-4.1-nano"), - GPT_4_O("gpt-4o"), - GPT_4_O_MINI("gpt-4o-mini"), - GPT_4_TURBO("gpt-4-turbo"), - CLAUDE_3_7_SONNET_LATEST("claude-3-7-sonnet-latest"), - CLAUDE_3_5_SONNET_LATEST("claude-3-5-sonnet-latest"), - CLAUDE_3_5_HAIKU_LATEST("claude-3-5-haiku-latest"), - CLAUDE_3_OPUS_LATEST("claude-3-opus-latest"), - MINISTRAL_3B_LATEST("ministral-3b-latest"), - CODESTRAL_LATEST("codestral-latest"), - MINISTRAL_LARGE_LATEST("mistral-large-latest"), - PIXTRAL_LARGE_LATEST("pixtral-large-latest"), - MINISTRAL_8B_LATEST("ministral-8b-latest"), -// QWEN_2_5_7B_INSTRUCT("qwen2.5-7b-instruct"), -// QWEN_2_5_14B_INSTRUCT("qwen2.5-14b-instruct"), -// QWEN_MAX("qwen-max"), -// QWEN_PLUS("qwen-plus"), -// QWEN_TURBO("qwen-turbo"), + GPT_5_4_MINI("gpt-5.4-mini"), + GPT_5_4_NANO("gpt-5.4-nano"), + CLAUDE_4_5_HAIKU("claude-haiku-4-5"), + CLAUDE_4_6_SONNET("claude-sonnet-4-6"), + MISTRAL_SMALL("mistral-small-latest"), + MISTRAL_MEDIUM("mistral-medium-latest"), + MINISTRAL_LARGE("mistral-large-latest"), + MINISTRAL_8B("ministral-8b-latest"), + MINISTRAL_14B("ministral-14b-latest") } enum class Locales(val value: String) { @@ -45,11 +32,6 @@ enum class Locales(val value: String) { RU("Русский") } -enum class Fonts(val value: String) { - NOTO_SANS("NotoSans"), - NOTO_SANS_ARABIC("NotoSans Arabic") -} - data class ModelStatus( val model: String, val active: MutableState diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/domain/UsfmBookSource.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/domain/UsfmBookSource.kt index 6f0d49a..7da8436 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/domain/UsfmBookSource.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/domain/UsfmBookSource.kt @@ -4,7 +4,6 @@ import org.bibletranslationtools.wat.data.Verse import org.bibletranslationtools.wat.platform.AppUsfmParser import org.bibletranslationtools.wat.platform.markers.CMarker import org.bibletranslationtools.wat.platform.markers.FMarker -import org.bibletranslationtools.wat.platform.markers.HMarker import org.bibletranslationtools.wat.platform.markers.TOC3Marker import org.bibletranslationtools.wat.platform.markers.TextBlock import org.bibletranslationtools.wat.platform.markers.UsfmDocument @@ -12,7 +11,6 @@ import org.bibletranslationtools.wat.platform.markers.VMarker import org.bibletranslationtools.wat.platform.markers.XMarker interface UsfmBookSource { - suspend fun import(bytes: ByteArray) suspend fun parse( usfm: String, bookSlug: String? = null, @@ -22,42 +20,6 @@ interface UsfmBookSource { class UsfmBookSourceImpl : UsfmBookSource { - override suspend fun import(bytes: ByteArray) { - try { - val usfm = bytes.decodeToString() - val usfmParser = AppUsfmParser(arrayListOf("s5"), true) - val document = usfmParser.parseFromString(usfm) - - val bookSlug = document - .getChildMarkers(TOC3Marker::class) - .firstOrNull() - ?.bookAbbreviation - ?.lowercase() - - val bookName = document - .getChildMarkers(HMarker::class) - .firstOrNull() - ?.headerText - - if (bookSlug == null || bookName == null) { - throw IllegalArgumentException("Book header is not complete.") - } - -// val existentBook = bookDataSource.getBySlug(bookSlug) -// if (existentBook != null) { -// bookDataSource.update(existentBook.copy(content = usfm)) -// } else { -// bookDataSource.add( -// slug = bookSlug, -// name = bookName, -// content = usfm -// ) -// } - } catch (e: Exception) { - throw IllegalArgumentException("Could not import file.", e) - } - } - override suspend fun parse( usfm: String, bookSlug: String?, @@ -72,27 +34,20 @@ class UsfmBookSourceImpl : UsfmBookSource { ?.bookAbbreviation ?.lowercase() ?: "unknown" - val bookNameFinal = document - .getChildMarkers(HMarker::class) - .firstOrNull() - ?.headerText ?: "Unknown" - - return getVerses(document, bookSlugFinal, bookNameFinal) + return getVerses(document, bookSlugFinal) } private fun getVerses( document: UsfmDocument, - bookSlug: String, - bookName: String + bookSlug: String ): List { return document.getChildMarkers(CMarker::class).map { chapter -> chapter.getChildMarkers(VMarker::class).map { verse -> Verse( - number = verse.startingVerse, - text = verse.getText(), - bookSlug = bookSlug, - bookName = bookName, - chapter = chapter.number + book = bookSlug, + chapter = chapter.number, + verse = verse.verseNumber, + text = verse.getText() ) } }.flatten() diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/domain/WatApi.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/domain/WatApi.kt index 5f168f4..159ef60 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/domain/WatApi.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/domain/WatApi.kt @@ -5,10 +5,7 @@ import com.appstractive.jwt.from import config.BuildConfig import io.ktor.client.HttpClient import io.ktor.client.call.body -import io.ktor.client.statement.bodyAsChannel import io.ktor.http.encodeURLPathPart -import io.ktor.utils.io.readRemaining -import kotlinx.io.readByteArray import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -23,6 +20,7 @@ import org.bibletranslationtools.wat.http.delete import org.bibletranslationtools.wat.http.get import org.bibletranslationtools.wat.http.post import org.bibletranslationtools.wat.http.postFile +import org.bibletranslationtools.wat.http.put import org.jetbrains.compose.resources.getString import wordanalysistool.composeapp.generated.resources.Res import wordanalysistool.composeapp.generated.resources.unknown_error @@ -52,21 +50,37 @@ enum class WordStatus(val value: Int) { @Serializable data class WordRequest( - @SerialName("batch_id") - val batchId: String, val word: String, - val correct: Boolean? + val correct: Boolean +) + +@Serializable +data class WordsRequest( + val batchId: String, + val words: List ) @Serializable data class BatchRequest( val language: String, - val words: List, + val words: List, val models: List ) +@Serializable +data class WordData( + val word: String, + val ref: String +) + @Serializable data class BatchProgress( + val correct: Int, + val incorrect: Int, + val name: Int, + @SerialName("review_needed") + val reviewNeeded: Int, + val reviewed: Int, val completed: Int, val total: Int ) @@ -107,6 +121,7 @@ data class ModelResponse( @Serializable data class WordResponse( val word: String, + val ref: String, val correct: Boolean?, val results: List ) @@ -154,37 +169,48 @@ interface WatApi { suspend fun getAuthUrl(): ApiResult suspend fun getAuthToken(): ApiResult suspend fun verifyUser(accessToken: String): ApiResult - suspend fun getBatch( + suspend fun getBatchStats( ietfCode: String, resourceType: String, accessToken: String ): ApiResult - + suspend fun getBatchReport( + ietfCode: String, + resourceType: String, + accessToken: String + ): ApiResult + suspend fun getReviewPage( + ietfCode: String, + resourceType: String, + page: Int, + limit: Int, + accessToken: String + ): ApiResult suspend fun createBatch( ietfCode: String, resourceType: String, request: BatchRequest, accessToken: String ): ApiResult - suspend fun deleteBatch( batchId: String, accessToken: String ): ApiResult - - suspend fun cancelBatch( + suspend fun pauseBatch( batchId: String, accessToken: String ): ApiResult - - suspend fun updateWordCorrect( - request: WordRequest, + suspend fun updateWordsCorrect( + request: WordsRequest, accessToken: String ): ApiResult - suspend fun getBatchesInProgress( accessToken: String ): ApiResult, NetworkError> + suspend fun resetReviewProgress( + batchId: String, + accessToken: String + ): ApiResult } class WatApiImpl( @@ -209,7 +235,7 @@ class WatApiImpl( val response = get(httpClient, "$BASE_URL/auth/tokens/$state") return when { response.data != null -> { - ApiResult.Success(response.data.body()) + ApiResult.Success(response.data.body()) } response.error != null -> { @@ -217,7 +243,11 @@ class WatApiImpl( } else -> ApiResult.Error( - NetworkError(ErrorType.Unknown, -1, getString(Res.string.unknown_error)) + NetworkError( + ErrorType.Unknown, + -1, + getString(Res.string.unknown_error) + ) ) } } @@ -234,8 +264,41 @@ class WatApiImpl( return when { response.data != null -> { ApiResult.Success( - response.data.body() + response.data.body() + ) + } + + response.error != null -> { + ApiResult.Error(response.error) + } + + else -> ApiResult.Error( + NetworkError( + ErrorType.Unknown, + -1, + getString(Res.string.unknown_error) ) + ) + } + } + + override suspend fun getBatchReport( + ietfCode: String, + resourceType: String, + accessToken: String + ): ApiResult { + val response = get( + httpClient = httpClient, + url = "$BASE_URL/api/report/$ietfCode/$resourceType", + headers = mapOf( + "Authorization" to "Bearer $accessToken", + "Content-Type" to "application/json" + ) + ) + + return when { + response.data != null -> { + ApiResult.Success(response.data.body()) } response.error != null -> { @@ -243,19 +306,23 @@ class WatApiImpl( } else -> ApiResult.Error( - NetworkError(ErrorType.Unknown, -1, getString(Res.string.unknown_error)) + NetworkError( + ErrorType.Unknown, + -1, + getString(Res.string.unknown_error) + ) ) } } - override suspend fun getBatch( + override suspend fun getBatchStats( ietfCode: String, resourceType: String, accessToken: String ): ApiResult { val response = get( httpClient = httpClient, - url = "$BASE_URL/api/batch/$ietfCode/$resourceType", + url = "$BASE_URL/api/stats/$ietfCode/$resourceType", headers = mapOf( "Authorization" to "Bearer $accessToken", "Content-Type" to "application/json" @@ -264,12 +331,47 @@ class WatApiImpl( return when { response.data != null -> { - val channel = response.data.bodyAsChannel() - val byteArray = channel.readRemaining().readByteArray() - val jsonString = byteArray.decodeToString() + ApiResult.Success(response.data.body()) + } + response.error != null -> { + ApiResult.Error(response.error) + } + + else -> ApiResult.Error( + NetworkError( + ErrorType.Unknown, + -1, + getString(Res.string.unknown_error) + ) + ) + } + } + + override suspend fun getReviewPage( + ietfCode: String, + resourceType: String, + page: Int, + limit: Int, + accessToken: String + ): ApiResult { + val response = get( + httpClient = httpClient, + url = "$BASE_URL/api/review/$ietfCode/$resourceType", + headers = mapOf( + "Authorization" to "Bearer $accessToken", + "Content-Type" to "application/json" + ), + params = mapOf( + "page" to page.toString(), + "limit" to limit.toString() + ) + ) + + return when { + response.data != null -> { ApiResult.Success( - JsonLenient.decodeFromString(jsonString) + response.data.body() ) } @@ -278,7 +380,11 @@ class WatApiImpl( } else -> ApiResult.Error( - NetworkError(ErrorType.Unknown, -1, getString(Res.string.unknown_error)) + NetworkError( + ErrorType.Unknown, + -1, + getString(Res.string.unknown_error) + ) ) } } @@ -303,7 +409,7 @@ class WatApiImpl( return when { response.data != null -> { ApiResult.Success( - response.data.body() + response.data.body() ) } @@ -312,19 +418,23 @@ class WatApiImpl( } else -> ApiResult.Error( - NetworkError(ErrorType.Unknown, -1, getString(Res.string.unknown_error)) + NetworkError( + ErrorType.Unknown, + -1, + getString(Res.string.unknown_error) + ) ) } } } - override suspend fun cancelBatch( + override suspend fun pauseBatch( batchId: String, accessToken: String ): ApiResult { val response = delete( httpClient = httpClient, - url = "$BASE_URL/api/batch/cancel/$batchId", + url = "$BASE_URL/api/batch/pause/$batchId", headers = mapOf( "Authorization" to "Bearer $accessToken", "Content-Type" to "application/json" @@ -333,7 +443,7 @@ class WatApiImpl( return when { response.data != null -> { ApiResult.Success( - response.data.body() + response.data.body() ) } @@ -342,7 +452,11 @@ class WatApiImpl( } else -> ApiResult.Error( - NetworkError(ErrorType.Unknown, -1, getString(Res.string.unknown_error)) + NetworkError( + ErrorType.Unknown, + -1, + getString(Res.string.unknown_error) + ) ) } } @@ -362,7 +476,7 @@ class WatApiImpl( return when { response.data != null -> { ApiResult.Success( - response.data.body() + response.data.body() ) } @@ -371,18 +485,22 @@ class WatApiImpl( } else -> ApiResult.Error( - NetworkError(ErrorType.Unknown, -1, getString(Res.string.unknown_error)) + NetworkError( + ErrorType.Unknown, + -1, + getString(Res.string.unknown_error) + ) ) } } - override suspend fun updateWordCorrect( - request: WordRequest, + override suspend fun updateWordsCorrect( + request: WordsRequest, accessToken: String ): ApiResult { val response = post( httpClient = httpClient, - url = "$BASE_URL/api/word", + url = "$BASE_URL/api/words", body = request, headers = mapOf( "Authorization" to "Bearer $accessToken", @@ -392,7 +510,7 @@ class WatApiImpl( return when { response.data != null -> { ApiResult.Success( - response.data.body() + response.data.body() ) } @@ -401,7 +519,11 @@ class WatApiImpl( } else -> ApiResult.Error( - NetworkError(ErrorType.Unknown, -1, getString(Res.string.unknown_error)) + NetworkError( + ErrorType.Unknown, + -1, + getString(Res.string.unknown_error) + ) ) } } @@ -420,7 +542,7 @@ class WatApiImpl( return when { response.data != null -> { ApiResult.Success( - response.data.body>() + response.data.body() ) } @@ -429,7 +551,46 @@ class WatApiImpl( } else -> ApiResult.Error( - NetworkError(ErrorType.Unknown, -1, getString(Res.string.unknown_error)) + NetworkError( + ErrorType.Unknown, + -1, + getString(Res.string.unknown_error) + ) + ) + } + } + + override suspend fun resetReviewProgress( + batchId: String, + accessToken: String + ): ApiResult { + println(accessToken) + println(batchId) + val response = put( + httpClient = httpClient, + url = "$BASE_URL/api/review/reset/$batchId", + headers = mapOf( + "Authorization" to "Bearer $accessToken", + "Content-Type" to "application/json" + ) + ) + return when { + response.data != null -> { + ApiResult.Success( + response.data.body() + ) + } + + response.error != null -> { + ApiResult.Error(response.error) + } + + else -> ApiResult.Error( + NetworkError( + ErrorType.Unknown, + -1, + getString(Res.string.unknown_error) + ) ) } } diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/domain/WordDataSource.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/domain/WordDataSource.kt deleted file mode 100644 index a83b1fb..0000000 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/domain/WordDataSource.kt +++ /dev/null @@ -1,55 +0,0 @@ -package org.bibletranslationtools.wat.domain - -import io.github.mxaln.kotlin.document.store.core.KotlinDocumentStore -import io.github.mxaln.kotlin.document.store.core.ObjectCollection -import io.github.mxaln.kotlin.document.store.core.find -import io.github.mxaln.kotlin.document.store.core.getObjectCollection -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.withContext -import org.bibletranslationtools.wat.data.Word - -interface WordDataSource { - suspend fun getAll(): ObjectCollection - suspend fun getById(id: Long): Word? - suspend fun getByWord(original: String): Word? - suspend fun add(word: Word) - suspend fun update(word: Word) - suspend fun delete(id: Long) -} - -class WordDataSourceImpl(private val db: KotlinDocumentStore) : WordDataSource { - override suspend fun getAll(): ObjectCollection { - return db.getObjectCollection("words") - } - - override suspend fun getById(id: Long): Word? { - return withContext(Dispatchers.Default) { - getAll().findById(id) - } - } - - override suspend fun getByWord(original: String): Word? { - return withContext(Dispatchers.Default) { - getAll().find("original", original).firstOrNull() - } - } - - override suspend fun add(word: Word) { - withContext(Dispatchers.Default) { - getAll().insert(word) - } - } - - override suspend fun update(word: Word) { - withContext(Dispatchers.Default) { - getAll().insert(word) - } - } - - override suspend fun delete(id: Long) { - withContext(Dispatchers.Default) { - getAll().removeById(id) - } - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/http/ApiResult.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/http/ApiResult.kt index 73d8119..6bf62f6 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/http/ApiResult.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/http/ApiResult.kt @@ -6,6 +6,7 @@ import io.ktor.client.request.delete import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.post +import io.ktor.client.request.put import io.ktor.client.request.setBody import io.ktor.client.statement.HttpResponse import io.ktor.http.ContentType @@ -62,7 +63,8 @@ typealias EmptyResult = ApiResult suspend fun get( httpClient: HttpClient, url: String, - headers: Map = emptyMap() + headers: Map = emptyMap(), + params: Map = emptyMap() ): NetworkResponse { return runNetworkRequest { httpClient.get(url) { @@ -71,6 +73,11 @@ suspend fun get( header(key, value) } } + url { + params.forEach { (key, value) -> + parameters.append(key, value) + } + } } } } @@ -91,6 +98,22 @@ suspend fun delete( } } +suspend fun put( + httpClient: HttpClient, + url: String, + headers: Map = emptyMap() +): NetworkResponse { + return runNetworkRequest { + httpClient.put(url) { + headers { + headers.forEach { (key, value) -> + header(key, value) + } + } + } + } +} + suspend fun postFile( httpClient: HttpClient, url: String, diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/navigation/PlatformUrlHooks.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/navigation/PlatformUrlHooks.kt new file mode 100644 index 0000000..46cb67c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/navigation/PlatformUrlHooks.kt @@ -0,0 +1,12 @@ +package org.bibletranslationtools.wat.navigation + +import androidx.compose.runtime.Composable +import cafe.adriel.voyager.navigator.Navigator + +@Composable +expect fun BindNavigatorToPlatform(navigator: Navigator) + +internal expect fun performPushState(path: String) +internal expect fun performReplaceState(path: String) +internal expect fun performBackNavigation() +internal expect fun performReplaceAll(paths: List) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/navigation/UrlManager.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/navigation/UrlManager.kt new file mode 100644 index 0000000..8100233 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/navigation/UrlManager.kt @@ -0,0 +1,44 @@ +package org.bibletranslationtools.wat.navigation + +import androidx.compose.runtime.Composable +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.navigator.Navigator +import org.bibletranslationtools.wat.ui.ReviewScreen + +object UrlManager { + private var navigator: Navigator? = null + + @Composable + fun Init(navigator: Navigator) { + this.navigator = navigator + BindNavigatorToPlatform(navigator) + } + + fun push(screen: Screen) { + navigator?.push(screen) + performPushState(screenToPath(screen)) + } + + fun replaceAll(screen: Screen) { + navigator?.replaceAll(screen) + performReplaceState(screenToPath(screen)) + } + + fun replaceAll(screens: List) { + if (screens.isEmpty()) return + navigator?.replaceAll(screens) + performReplaceAll(screens.map { screenToPath(it) }) + } + + fun pop() { + navigator?.pop() + performBackNavigation() + } + + private fun screenToPath(screen: Screen): String { + return when (screen) { + is ReviewScreen -> "/${screen.ietfCode}_${screen.resourceType}" + else -> "/" + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/platform/FileCache.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/platform/FileCache.kt new file mode 100644 index 0000000..0da9d59 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/platform/FileCache.kt @@ -0,0 +1,16 @@ +package org.bibletranslationtools.wat.platform + +expect class FileCache { + /** + * Retrieves file data from the cache for a given URL. + * @return The file content as a ByteArray, or null if not found. + */ + suspend fun get(url: String): ByteArray? + + /** + * Stores file data in the cache under a given URL. + */ + suspend fun put(url: String, data: ByteArray) +} + +expect fun createFileCache(): FileCache \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/platform/Platform.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/platform/Platform.kt index afb4327..d632ecb 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/platform/Platform.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/platform/Platform.kt @@ -1,9 +1,8 @@ package org.bibletranslationtools.wat.platform -import io.github.mxaln.kotlin.document.store.core.DataStore import io.ktor.client.engine.HttpClientEngine -expect val dbStore: DataStore expect val httpClientEngine: HttpClientEngine expect val appDirPath: String -expect fun applyLocale(iso: String) \ No newline at end of file +expect fun applyLocale(iso: String) +expect suspend fun saveFile(bytes: ByteArray, filename: String, extension: String) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/AnalyzeScreen.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/AnalyzeScreen.kt index 5bc71fe..643a928 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/AnalyzeScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/AnalyzeScreen.kt @@ -1,38 +1,36 @@ package org.bibletranslationtools.wat.ui -import ComboBox -import Option -import OptionIcon +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Logout -import androidx.compose.material.icons.filled.ArrowDownward -import androidx.compose.material.icons.filled.ArrowUpward -import androidx.compose.material.icons.filled.Cancel -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Circle -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Sync +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Save import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -44,7 +42,8 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -53,63 +52,54 @@ import cafe.adriel.voyager.koin.koinScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import dev.burnoo.compose.remembersetting.rememberBooleanSetting -import dev.burnoo.compose.remembersetting.rememberStringSetting import dev.burnoo.compose.remembersetting.rememberStringSettingOrNull -import org.bibletranslationtools.wat.data.Consensus -import org.bibletranslationtools.wat.data.LanguageInfo -import org.bibletranslationtools.wat.data.SingletonWord -import org.bibletranslationtools.wat.data.Verse +import org.bibletranslationtools.wat.data.VerseRef import org.bibletranslationtools.wat.domain.BatchError import org.bibletranslationtools.wat.domain.Model import org.bibletranslationtools.wat.domain.Settings import org.bibletranslationtools.wat.domain.User +import org.bibletranslationtools.wat.navigation.UrlManager import org.bibletranslationtools.wat.ui.control.BatchInfo import org.bibletranslationtools.wat.ui.control.BatchProgress -import org.bibletranslationtools.wat.ui.control.ExtraAction -import org.bibletranslationtools.wat.ui.control.PageType -import org.bibletranslationtools.wat.ui.control.SingletonCard -import org.bibletranslationtools.wat.ui.control.SingletonRow +import org.bibletranslationtools.wat.ui.control.CustomTextButton +import org.bibletranslationtools.wat.ui.control.MessageToast +import org.bibletranslationtools.wat.ui.control.Status +import org.bibletranslationtools.wat.ui.control.StatusBar import org.bibletranslationtools.wat.ui.control.StatusBox -import org.bibletranslationtools.wat.ui.control.TopNavigationBar -import org.bibletranslationtools.wat.ui.dialogs.AlertDialog import org.bibletranslationtools.wat.ui.dialogs.BatchErrorDialog import org.bibletranslationtools.wat.ui.dialogs.ProgressDialog -import org.jetbrains.compose.resources.getString +import org.bibletranslationtools.wat.ui.theme.getFontFamilyForText import org.jetbrains.compose.resources.stringResource import org.koin.core.parameter.parametersOf import wordanalysistool.composeapp.generated.resources.Res -import wordanalysistool.composeapp.generated.resources.cancel_batch +import wordanalysistool.composeapp.generated.resources.admin +import wordanalysistool.composeapp.generated.resources.back import wordanalysistool.composeapp.generated.resources.delete_batch -import wordanalysistool.composeapp.generated.resources.likely_correct -import wordanalysistool.composeapp.generated.resources.likely_incorrect -import wordanalysistool.composeapp.generated.resources.logout -import wordanalysistool.composeapp.generated.resources.names +import wordanalysistool.composeapp.generated.resources.home +import wordanalysistool.composeapp.generated.resources.pause_batch import wordanalysistool.composeapp.generated.resources.process_words -import wordanalysistool.composeapp.generated.resources.review_needed +import wordanalysistool.composeapp.generated.resources.reset_review_progress import wordanalysistool.composeapp.generated.resources.save_report -import wordanalysistool.composeapp.generated.resources.sort_by_alphabet -import wordanalysistool.composeapp.generated.resources.sort_by_alphabet_desc -import wordanalysistool.composeapp.generated.resources.sort_by_reviewed -import wordanalysistool.composeapp.generated.resources.sort_words +import wordanalysistool.composeapp.generated.resources.settings +import wordanalysistool.composeapp.generated.resources.sign_out class AnalyzeScreen( - private val language: LanguageInfo, + val ietfCode: String, private val resourceType: String, - private val verses: List, + private val verses: VerseRef, private val user: User ) : Screen { @Composable override fun Content() { val viewModel = koinScreenModel { - parametersOf(language, resourceType, verses, user) + parametersOf(ietfCode, resourceType, verses, user) } val navigator = LocalNavigator.currentOrThrow val state by viewModel.state.collectAsStateWithLifecycle() val event by viewModel.event.collectAsStateWithLifecycle(AnalyzeEvent.Idle) - var selectedWord by remember { mutableStateOf(null) } val modelsState = Model.entries.mapNotNull { val active = rememberBooleanSetting(it.value, false).value @@ -122,47 +112,18 @@ class AnalyzeScreen( true ) - var wordsSorting by rememberStringSetting( - Settings.SORT_WORDS.name, - WordsSorting.ALPHABET.name - ) - val sorting = try { - WordsSorting.valueOf(wordsSorting) - } catch (_: Exception) { - WordsSorting.ALPHABET - } - - var filteredSingletons by remember { mutableStateOf(state.singletons) } - var accessToken by rememberStringSettingOrNull(Settings.ACCESS_TOKEN.name) - val wordsListState = rememberLazyListState() val statuses = remember { mutableStateListOf() } - var showStatuses by remember { mutableStateOf(false) } var batchError by remember { mutableStateOf(null) } - val localizedSorting = WordsSorting.entries.associateWith { localizeSorting(it) } - var adminActions by remember { mutableStateOf>(emptyList()) } - LaunchedEffect(event) { when (event) { - is AnalyzeEvent.WordsSorted -> { - wordsListState.animateScrollToItem(0) - viewModel.onEvent(AnalyzeEvent.Idle) - } is AnalyzeEvent.Logout -> { accessToken = null navigator.popUntilRoot() } - is AnalyzeEvent.UpdateSelectedWord -> { - val correct = (event as AnalyzeEvent.UpdateSelectedWord).value - selectedWord = selectedWord?.copy(correct = correct) - } - is AnalyzeEvent.RefreshSelectedWord -> { - selectedWord = state.singletons.find { it.word == selectedWord?.word } - viewModel.onEvent(AnalyzeEvent.Idle) - } else -> Unit } } @@ -177,10 +138,6 @@ class AnalyzeScreen( viewModel.onEvent(AnalyzeEvent.FindSingletons(apostropheIsSeparator)) } - LaunchedEffect(state.singletons, wordsSorting) { - filteredSingletons = filterSingletons(state.singletons, sorting) - } - LaunchedEffect(state.status) { state.status?.let { if (statuses.size > 1_000) { @@ -190,158 +147,177 @@ class AnalyzeScreen( } } - LaunchedEffect(user) { - val processWordsText = getString(Res.string.process_words) - val cancelBatchText = getString(Res.string.cancel_batch) - val deleteBatchText = getString(Res.string.delete_batch) - - adminActions = if (user.admin) { - listOf( - ExtraAction( - title = processWordsText, - icon = Icons.Default.Sync, - onClick = { - viewModel.onEvent(AnalyzeEvent.BatchWords) - } - ), - ExtraAction( - title = cancelBatchText, - icon = Icons.Default.Cancel, - onClick = { - viewModel.onEvent(AnalyzeEvent.CancelBatch) - } - ), - ExtraAction( - title = deleteBatchText, - icon = Icons.Default.Delete, - onClick = { - viewModel.onEvent(AnalyzeEvent.DeleteBatch) - } - ) - ) - } else emptyList() - } - Scaffold( - topBar = { - TopNavigationBar( - title = "[${language.ietfCode}] ${language.name} - $resourceType", - user = user, - page = PageType.ANALYZE, - extraAction = (adminActions + listOf( - ExtraAction( - title = stringResource(Res.string.save_report), - icon = Icons.Default.Save, - onClick = { - viewModel.onEvent(AnalyzeEvent.SaveReport) - } - ), - ExtraAction( - title = stringResource(Res.string.logout), - icon = Icons.AutoMirrored.Filled.Logout, - onClick = { - accessToken = null - navigator.popUntilRoot() - } - ) - )).toTypedArray() - ) - } + containerColor = MaterialTheme.colorScheme.surface ) { paddingValues -> Box( modifier = Modifier.fillMaxSize() .padding(paddingValues) ) { - Column(modifier = Modifier.fillMaxSize()) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth() - .padding(16.dp) - .weight(1f) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxSize() + .padding(16.dp) + .padding(bottom = 32.dp) + ) { + Box( + modifier = Modifier + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = MaterialTheme.shapes.medium + ) + .weight(0.3f) ) { - Surface( - shape = RoundedCornerShape(8.dp), - modifier = Modifier.shadow(4.dp, RoundedCornerShape(8.dp)) - .weight(0.3f) + Column( + modifier = Modifier.padding(16.dp) ) { - Column(modifier = Modifier.padding(16.dp)) { - Column( - modifier = Modifier.padding(top = 8.dp) - ) { - BatchInfo(singletons = state.singletons) - Spacer(modifier = Modifier.height(8.dp)) - if (state.batchProgress >= 0) { - BatchProgress( - progress = state.batchProgress, - modifier = Modifier.fillMaxWidth() - ) - } - Spacer(modifier = Modifier.height(8.dp)) - ComboBox( - value = sorting, - options = WordsSorting.entries.map { sortingToOption(it) }, - onOptionSelected = { sort: WordsSorting -> - wordsSorting = sort.name - }, - valueConverter = { sort -> - localizedSorting[sort] ?: "" + CustomTextButton( + onClick = navigator::pop, + icon = Icons.AutoMirrored.Filled.ArrowBack, + text = stringResource(Res.string.back), + modifier = Modifier.align(Alignment.End) + ) + + Text( + text = stringResource(Res.string.admin), + fontSize = 28.sp, + fontWeight = FontWeight.W500 + ) + Text( + text = state.language?.name ?: "", + style = LocalTextStyle.current.copy( + textDirection = TextDirection.ContentOrLtr, + fontFamily = getFontFamilyForText(state.language?.name ?: "") + ), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy( + space = 4.dp, + alignment = Alignment.Bottom + ) + ) { + CustomTextButton( + onClick = { + viewModel.onEvent(AnalyzeEvent.BatchWords) + }, + icon = Icons.Default.Sync, + text = stringResource(Res.string.process_words) + ) + CustomTextButton( + onClick = { + viewModel.onEvent(AnalyzeEvent.PauseBatch) + }, + icon = Icons.Default.Pause, + text = stringResource(Res.string.pause_batch) + ) + CustomTextButton( + onClick = { + viewModel.onEvent(AnalyzeEvent.DeleteBatch) + }, + icon = Icons.Outlined.Delete, + text = stringResource(Res.string.delete_batch) + ) + + HorizontalDivider() + + state.batch?.let { batch -> + CustomTextButton( + onClick = { + viewModel.onEvent( + AnalyzeEvent.ResetReview(batch.id) + ) }, - label = stringResource(Res.string.sort_words) - ) - } - Spacer(modifier = Modifier.height(16.dp)) - LazyColumn(state = wordsListState) { - items(items = filteredSingletons, key = { it.word }) { singleton -> - SingletonRow( - singleton = singleton, - selected = selectedWord == singleton, - direction = language.direction, - onSelect = { selectedWord = singleton } + icon = Icons.Default.History, + text = stringResource( + Res.string.reset_review_progress ) - } + ) } + + CustomTextButton( + onClick = { + viewModel.onEvent(AnalyzeEvent.SaveReport) + }, + icon = Icons.Outlined.Save, + text = stringResource(Res.string.save_report) + ) } - } - selectedWord?.let { word -> - SingletonCard( - word = word, - onAnswer = { - viewModel.onEvent( - AnalyzeEvent.UpdateCorrect(word.word, it) + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy( + space = 4.dp, + alignment = Alignment.Bottom + ), + modifier = Modifier.weight(1f) + ) { + CustomTextButton( + onClick = { UrlManager.replaceAll(HomeScreen(user)) }, + icon = Icons.Default.Home, + text = stringResource(Res.string.home) + ) + CustomTextButton( + onClick = { + navigator.push(SettingsScreen(user)) + }, + icon = Icons.Default.Settings, + text = stringResource(Res.string.settings) + ) + CustomTextButton( + onClick = { + accessToken = null + UrlManager.replaceAll(LoginScreen()) + }, + icon = Icons.Default.Person, + text = stringResource( + Res.string.sign_out, + user.username ) - }, - modifier = Modifier.weight(0.7f) - ) - } ?: Spacer(modifier = Modifier.weight(0.7f)) + ) + } + } } - HorizontalDivider() - - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - .height(36.dp) - .padding(start = 16.dp) + Box( + modifier = Modifier + .weight(0.7f) + .fillMaxHeight() + .padding(start = 48.dp), ) { - val message = state.status?.let { - when (it.info) { - is String -> it.info - is BatchError -> it.info.message - else -> null + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .padding(top = 8.dp) + .width(500.dp) + ) { + BatchInfo( + info = state.batch?.details?.progress, + totalSingletons = state.singletons.size, + modifier = Modifier.fillMaxWidth() + ) + + if (state.batchProgress >= 0) { + BatchProgress( + progress = state.batchProgress, + modifier = Modifier.fillMaxWidth() + ) } - } ?: "" - Text( - text = message, - fontSize = 12.sp - ) - IconButton(onClick = { showStatuses = !showStatuses }) { - Icon(imageVector = Icons.Default.Info, contentDescription = null) } } } + StatusBar( + status = state.status ?: Status("", ""), + onToggleStatusBox = { showStatuses = !showStatuses }, + modifier = Modifier.align(Alignment.BottomCenter) + ) + if (showStatuses) { StatusBox( statuses = statuses, @@ -349,13 +325,23 @@ class AnalyzeScreen( modifier = Modifier.align(Alignment.BottomEnd) ) } - } - state.alert?.let { - AlertDialog( - message = it.message, - onDismiss = it.onClosed - ) + AnimatedVisibility( + visible = state.toast != null, + enter = slideInHorizontally(initialOffsetX = { it }) + fadeIn(), + exit = slideOutHorizontally(targetOffsetX = { it }) + fadeOut(), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp, bottom = 64.dp) + ) { + state.toast?.let { data -> + MessageToast( + type = data.type, + message = data.message, + onDismiss = data.onClose + ) + } + } } batchError?.let { @@ -371,81 +357,3 @@ class AnalyzeScreen( } } } - -@Composable -private fun sortingToOption(sorting: WordsSorting): Option { - return when (sorting) { - WordsSorting.ALPHABET -> Option( - value = sorting, - icon = OptionIcon(Icons.Default.ArrowUpward) - ) - WordsSorting.ALPHABET_DESC -> Option( - value = sorting, - icon = OptionIcon(Icons.Default.ArrowDownward) - ) - WordsSorting.NAME -> Option( - value = sorting, - icon = OptionIcon( - vector = Icons.Default.Circle, - tint = MaterialTheme.colorScheme.primary, - size = 12.dp - ) - ) - WordsSorting.LIKELY_CORRECT -> Option( - value = sorting, - icon = OptionIcon( - vector = Icons.Default.Circle, - tint = MaterialTheme.colorScheme.tertiary, - size = 12.dp - ) - ) - WordsSorting.LIKELY_INCORRECT -> Option( - value = sorting, - icon = OptionIcon( - vector = Icons.Default.Circle, - tint = MaterialTheme.colorScheme.error, - size = 12.dp - ) - ) - WordsSorting.NEEDS_REVIEW -> Option( - value = sorting, - icon = OptionIcon( - vector = Icons.Default.Circle, - tint = MaterialTheme.colorScheme.secondary, - size = 12.dp - ) - ) - WordsSorting.REVIEWED -> Option( - value = sorting, - icon = OptionIcon(Icons.Default.CheckCircle) - ) - } -} - -@Composable -private fun localizeSorting(sorting: WordsSorting): String { - return when (sorting) { - WordsSorting.ALPHABET -> stringResource(Res.string.sort_by_alphabet) - WordsSorting.ALPHABET_DESC -> stringResource(Res.string.sort_by_alphabet_desc) - WordsSorting.LIKELY_CORRECT -> stringResource(Res.string.likely_correct) - WordsSorting.LIKELY_INCORRECT -> stringResource(Res.string.likely_incorrect) - WordsSorting.NEEDS_REVIEW -> stringResource(Res.string.review_needed) - WordsSorting.NAME -> stringResource(Res.string.names) - WordsSorting.REVIEWED -> stringResource(Res.string.sort_by_reviewed) - } -} - -private fun filterSingletons( - singletons: List, - filter: WordsSorting -): List { - return when (filter) { - WordsSorting.ALPHABET -> singletons.sortedBy { it.word.lowercase() } - WordsSorting.ALPHABET_DESC -> singletons.sortedByDescending { it.word.lowercase() } - WordsSorting.LIKELY_CORRECT -> singletons.filter { it.result?.consensus == Consensus.LIKELY_CORRECT } - WordsSorting.LIKELY_INCORRECT -> singletons.filter { it.result?.consensus == Consensus.LIKELY_INCORRECT } - WordsSorting.NEEDS_REVIEW -> singletons.filter { it.result?.consensus == Consensus.NEEDS_REVIEW } - WordsSorting.NAME -> singletons.filter { it.result?.consensus == Consensus.NAME } - WordsSorting.REVIEWED -> singletons.filter { it.correct != null } - } -} diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/AnalyzeViewModel.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/AnalyzeViewModel.kt index b684a5a..790c71a 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/AnalyzeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/AnalyzeViewModel.kt @@ -5,7 +5,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope -import io.github.vinceglb.filekit.core.FileKit import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel @@ -13,115 +12,102 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.datetime.Clock import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime -import org.bibletranslationtools.wat.data.Alert import org.bibletranslationtools.wat.data.Consensus import org.bibletranslationtools.wat.data.ConsensusResult import org.bibletranslationtools.wat.data.LanguageInfo import org.bibletranslationtools.wat.data.ModelStatus import org.bibletranslationtools.wat.data.Progress import org.bibletranslationtools.wat.data.SingletonWord +import org.bibletranslationtools.wat.data.ToastInfo +import org.bibletranslationtools.wat.data.ToastType import org.bibletranslationtools.wat.data.Verse +import org.bibletranslationtools.wat.data.VerseRef import org.bibletranslationtools.wat.domain.Batch import org.bibletranslationtools.wat.domain.BatchRequest import org.bibletranslationtools.wat.domain.BatchStatus +import org.bibletranslationtools.wat.domain.BielGraphQlApi import org.bibletranslationtools.wat.domain.MODELS_SIZE import org.bibletranslationtools.wat.domain.ModelResponse import org.bibletranslationtools.wat.domain.User import org.bibletranslationtools.wat.domain.WatApi -import org.bibletranslationtools.wat.domain.WordRequest +import org.bibletranslationtools.wat.domain.WordData import org.bibletranslationtools.wat.domain.WordResponse import org.bibletranslationtools.wat.domain.WordStatus import org.bibletranslationtools.wat.format import org.bibletranslationtools.wat.http.ErrorType import org.bibletranslationtools.wat.http.onError import org.bibletranslationtools.wat.http.onSuccess -import org.bibletranslationtools.wat.ui.AnalyzeEvent.RefreshSelectedWord +import org.bibletranslationtools.wat.platform.saveFile +import org.bibletranslationtools.wat.ui.control.Status import org.jetbrains.compose.resources.getString import wordanalysistool.composeapp.generated.resources.Res import wordanalysistool.composeapp.generated.resources.all_results_received -import wordanalysistool.composeapp.generated.resources.batch_cancelled import wordanalysistool.composeapp.generated.resources.batch_deleted -import wordanalysistool.composeapp.generated.resources.batch_not_cancelled import wordanalysistool.composeapp.generated.resources.batch_not_deleted -import wordanalysistool.composeapp.generated.resources.cancelling_batch +import wordanalysistool.composeapp.generated.resources.batch_not_paused +import wordanalysistool.composeapp.generated.resources.batch_paused import wordanalysistool.composeapp.generated.resources.creating_batch import wordanalysistool.composeapp.generated.resources.deleting_batch import wordanalysistool.composeapp.generated.resources.finding_singleton_words +import wordanalysistool.composeapp.generated.resources.generating_report import wordanalysistool.composeapp.generated.resources.invalid_batch_id -import wordanalysistool.composeapp.generated.resources.likely_correct -import wordanalysistool.composeapp.generated.resources.likely_incorrect -import wordanalysistool.composeapp.generated.resources.name -import wordanalysistool.composeapp.generated.resources.no import wordanalysistool.composeapp.generated.resources.no_model_selected +import wordanalysistool.composeapp.generated.resources.pausing_batch import wordanalysistool.composeapp.generated.resources.report_saved -import wordanalysistool.composeapp.generated.resources.review_needed +import wordanalysistool.composeapp.generated.resources.reset_review_progress_success +import wordanalysistool.composeapp.generated.resources.resetting_review_progress import wordanalysistool.composeapp.generated.resources.token_invalid -import wordanalysistool.composeapp.generated.resources.updating_word +import wordanalysistool.composeapp.generated.resources.unknown_error import wordanalysistool.composeapp.generated.resources.wrong_model_selected -import wordanalysistool.composeapp.generated.resources.yes +import kotlin.time.Clock +import kotlin.time.ExperimentalTime private const val BATCH_REQUEST_DELAY = 10000L -data class Status( - val info: Any, - val time: String -) - data class AnalyzeState( val batch: Batch? = null, val batchProgress: Float = -1f, val singletons: List = emptyList(), val prompt: String? = null, - val sorting: WordsSorting = WordsSorting.ALPHABET, val models: List = emptyList(), - val alert: Alert? = null, + val toast: ToastInfo? = null, val progress: Progress? = null, - val status: Status? = null + val status: Status? = null, + val language: LanguageInfo? = null ) sealed class AnalyzeEvent { data object Idle : AnalyzeEvent() data object BatchWords : AnalyzeEvent() - data object CancelBatch: AnalyzeEvent() + data object PauseBatch: AnalyzeEvent() data object DeleteBatch : AnalyzeEvent() - data object WordsSorted : AnalyzeEvent() data object SaveReport : AnalyzeEvent() - data object RefreshSelectedWord : AnalyzeEvent() + data class ResetReview(val batchId: String) : AnalyzeEvent() data object Logout : AnalyzeEvent() data class UpdateModels(val value: List) : AnalyzeEvent() data class FindSingletons(val apostropheIsSeparator: Boolean) : AnalyzeEvent() - data class UpdateCorrect(val word: String, val correct: Boolean?): AnalyzeEvent() - data class UpdateSelectedWord(val value: Boolean?): AnalyzeEvent() -} - -enum class WordsSorting { - ALPHABET, - ALPHABET_DESC, - NAME, - LIKELY_CORRECT, - LIKELY_INCORRECT, - NEEDS_REVIEW, - REVIEWED } class AnalyzeViewModel( - private val language: LanguageInfo, + private val ietfCode: String, private val resourceType: String, - private val verses: List, + private val verses: VerseRef, private val user: User, - private val watApi: WatApi + private val watApi: WatApi, + private val bielGraphQlApi: BielGraphQlApi ) : ScreenModel { private var _state = MutableStateFlow(AnalyzeState()) val state: StateFlow = _state + .onStart { loadLanguage(ietfCode) } .stateIn( scope = screenModelScope, started = SharingStarted.WhileSubscribed(5000), @@ -133,26 +119,39 @@ class AnalyzeViewModel( private var fetchJob by mutableStateOf(null) - private val apostropheRegex = "[\\p{L}'’]+(? findSingletonWords(event.apostropheIsSeparator) + is AnalyzeEvent.FindSingletons -> findSingletonWords( + event.apostropheIsSeparator + ) is AnalyzeEvent.UpdateModels -> updateModels(event.value) is AnalyzeEvent.BatchWords -> createBatch() - is AnalyzeEvent.CancelBatch -> cancelBatch() + is AnalyzeEvent.PauseBatch -> pauseBatch() is AnalyzeEvent.DeleteBatch -> deleteBatch() is AnalyzeEvent.SaveReport -> saveReport() - is AnalyzeEvent.UpdateCorrect -> updateWordCorrect(event.word, event.correct) + is AnalyzeEvent.ResetReview -> resetReview(event.batchId) else -> resetChannel() } } + private fun loadLanguage(ietfCode: String) { + screenModelScope.launch { + _state.update { + it.copy(language = bielGraphQlApi.getLanguageInfo(ietfCode)) + } + } + } + private fun findSingletonWords(apostropheIsSeparator: Boolean) { screenModelScope.launch { updateProgress( - Progress(0f, getString(Res.string.finding_singleton_words)) + Progress( + 0f, + getString(Res.string.finding_singleton_words) + ) ) val totalVerses = verses.size @@ -162,7 +161,7 @@ class AnalyzeViewModel( } else apostropheRegex withContext(Dispatchers.Default) { - verses.forEachIndexed { index, verse -> + verses.values.forEachIndexed { index, verse -> val words = wordsRegex.findAll(verse.text).map { it.value } words.forEach { word -> @@ -207,11 +206,12 @@ class AnalyzeViewModel( BatchStatus.TERMINATED, BatchStatus.UNKNOWN ) + while (status !in completionStatuses) { updateStatus("Fetching batch status...") - watApi.getBatch( - language.ietfCode, + watApi.getBatchStats( + ietfCode, resourceType, user.token.accessToken ).onSuccess { batch -> @@ -221,12 +221,13 @@ class AnalyzeViewModel( updateBatch(batch) val current = batch.details.progress.completed - val total = batch.details.progress.total.toFloat() + val total = batch.details.progress.total + val progress = current / total.toFloat() if (total > 0) { - updateBatchProgress(current / total) + updateBatchProgress(progress) updateStatus( - "Current batch progress: ${((current / total) * 100).toInt()}" + "Current batch progress: ${(progress * 100).toInt()}" ) } @@ -236,13 +237,17 @@ class AnalyzeViewModel( }.onError { when (it.type) { ErrorType.Unauthorized -> { - updateAlert( - Alert(getString(Res.string.token_invalid)) { - screenModelScope.launch { - _event.send(AnalyzeEvent.Logout) - updateAlert(null) + updateToast( + ToastInfo( + type = ToastType.Error, + message = getString(Res.string.token_invalid), + onClose = { + screenModelScope.launch { + _event.send(AnalyzeEvent.Logout) + updateToast(null) + } } - } + ) ) } else -> { @@ -259,8 +264,6 @@ class AnalyzeViewModel( delay(BATCH_REQUEST_DELAY) } - _event.send(RefreshSelectedWord) - updateBatchProgress(-1f) updateStatus("Batch results received") updateStatus("Idle") @@ -270,33 +273,45 @@ class AnalyzeViewModel( private fun createBatch() { screenModelScope.launch { if (_state.value.models.isEmpty()) { - updateAlert( - Alert(getString(Res.string.no_model_selected)) { - updateAlert(null) - } + updateToast( + ToastInfo( + type = ToastType.Error, + message = getString(Res.string.no_model_selected), + onClose = { updateToast(null) } + ) ) return@launch } if (_state.value.models.size != MODELS_SIZE) { - updateAlert( - Alert(getString(Res.string.wrong_model_selected, MODELS_SIZE)) { - updateAlert(null) - } + updateToast( + ToastInfo( + type = ToastType.Error, + message = getString( + Res.string.wrong_model_selected, + MODELS_SIZE + ), + onClose = { updateToast(null) } + ) ) return@launch } - updateProgress(Progress(-1f, getString(Res.string.creating_batch))) + updateProgress(Progress( + -1f, + getString(Res.string.creating_batch)) + ) val singletons = _state.value.singletons .filter { it.result == null } if (singletons.isEmpty()) { - updateAlert( - Alert(getString(Res.string.all_results_received)) { - updateAlert(null) - } + updateToast( + ToastInfo( + type = ToastType.Info, + message = getString(Res.string.all_results_received), + onClose = { updateToast(null) } + ) ) updateProgress(null) return@launch @@ -305,13 +320,18 @@ class AnalyzeViewModel( updateStatus("Sending batch request...") val request = BatchRequest( - language = language.angName, - words = singletons.map { it.word }, + language = state.value.language?.angName ?: "unknown", + words = singletons.map { + WordData( + it.word, + it.ref.toString() + ) + }, models = _state.value.models ) watApi.createBatch( - language.ietfCode, + ietfCode, resourceType, request, user.token.accessToken @@ -321,22 +341,28 @@ class AnalyzeViewModel( }.onError { when (it.type) { ErrorType.Unauthorized -> { - updateAlert( - Alert(getString(Res.string.token_invalid)) { - screenModelScope.launch { - _event.send(AnalyzeEvent.Logout) - updateAlert(null) + updateToast( + ToastInfo( + type = ToastType.Error, + message = getString(Res.string.token_invalid), + onClose = { + screenModelScope.launch { + _event.send(AnalyzeEvent.Logout) + updateToast(null) + } } - } + ) ) } else -> { updateStatus(it.description) - updateAlert( - Alert(it.description ?: "Error code: ${it.code}") { - updateAlert(null) - } + updateToast( + ToastInfo( + type = ToastType.Error, + message = it.description ?: "Error code: ${it.code}", + onClose = { updateToast(null) } + ) ) } } @@ -346,46 +372,60 @@ class AnalyzeViewModel( } } - private fun cancelBatch() { + private fun pauseBatch() { screenModelScope.launch { if (_state.value.batch == null) { - updateAlert( - Alert(getString(Res.string.invalid_batch_id)) { - updateAlert(null) - } + updateToast( + ToastInfo( + type = ToastType.Error, + message = getString(Res.string.invalid_batch_id), + onClose = { updateToast(null) } + ) ) return@launch } - updateStatus("Cancelling batch results...") - updateProgress(Progress(-1f, getString(Res.string.cancelling_batch))) + updateStatus("Pausing batch results...") + updateProgress(Progress( + -1f, + getString(Res.string.pausing_batch)) + ) - watApi.cancelBatch(_state.value.batch!!.id, user.token.accessToken) + watApi.pauseBatch( + _state.value.batch!!.id, + user.token.accessToken + ) .onSuccess { cancelled -> if (cancelled) { - updateStatus("Batch cancelled") - updateAlert( - Alert(getString(Res.string.batch_cancelled)) { - updateAlert(null) - } + updateStatus("Batch paused") + updateToast( + ToastInfo( + type = ToastType.Success, + message = getString(Res.string.batch_paused), + onClose = { updateToast(null) } + ) ) fetchJob?.cancel() updateBatchProgress(-1f) } else { - updateStatus("Could not cancel batch.") - updateAlert( - Alert(getString(Res.string.batch_not_cancelled)) { - updateAlert(null) - } + updateStatus("Could not pause batch.") + updateToast( + ToastInfo( + type = ToastType.Error, + message = getString(Res.string.batch_not_paused), + onClose = { updateToast(null) } + ) ) } } .onError { updateStatus(it.description) - updateAlert( - Alert(it.description ?: "") { - updateAlert(null) - } + updateToast( + ToastInfo( + type = ToastType.Error, + message = it.description ?: getString(Res.string.unknown_error), + onClose = { updateToast(null) } + ) ) } @@ -396,18 +436,26 @@ class AnalyzeViewModel( private fun deleteBatch() { screenModelScope.launch { if (_state.value.batch == null) { - updateAlert( - Alert(getString(Res.string.invalid_batch_id)) { - updateAlert(null) - } + updateToast( + ToastInfo( + type = ToastType.Error, + message = getString(Res.string.invalid_batch_id), + onClose = { updateToast(null) } + ) ) return@launch } updateStatus("Deleting batch results...") - updateProgress(Progress(-1f, getString(Res.string.deleting_batch))) + updateProgress(Progress( + -1f, + getString(Res.string.deleting_batch)) + ) - watApi.deleteBatch(_state.value.batch!!.id, user.token.accessToken) + watApi.deleteBatch( + _state.value.batch!!.id, + user.token.accessToken + ) .onSuccess { deleted -> if (deleted) { updateBatch(null) @@ -417,72 +465,34 @@ class AnalyzeViewModel( it.copy(result = null, correct = null) } ) - updateAlert( - Alert(getString(Res.string.batch_deleted)) { - updateAlert(null) - } + updateToast( + ToastInfo( + type = ToastType.Success, + message = getString(Res.string.batch_deleted), + onClose = { updateToast(null) } + ) ) fetchJob?.cancel() updateBatchProgress(-1f) } else { updateStatus("Could not delete batch results.") - updateAlert( - Alert(getString(Res.string.batch_not_deleted)) { - updateAlert(null) - } + updateToast( + ToastInfo( + type = ToastType.Error, + message = getString(Res.string.batch_not_deleted), + onClose = { updateToast(null) } + ) ) } } .onError { updateStatus(it.description) - updateAlert( - Alert(it.description ?: "") { - updateAlert(null) - } - ) - } - - updateProgress(null) - } - } - - private fun updateWordCorrect(word: String, correct: Boolean?) { - screenModelScope.launch { - if (_state.value.batch == null) { - updateAlert( - Alert(getString(Res.string.invalid_batch_id)) { - updateAlert(null) - } - ) - return@launch - } - - updateProgress(Progress(-1f, getString(Res.string.updating_word))) - - val request = WordRequest( - _state.value.batch!!.id, - word, - correct - ) - watApi.updateWordCorrect(request, user.token.accessToken) - .onSuccess { - updateSingletons( - _state.value.singletons.map { singleton -> - if (singleton.word == request.word) { - singleton.copy(correct = request.correct) - } else { - singleton - } - } - ) - _event.send(AnalyzeEvent.UpdateSelectedWord(request.correct)) - } - .onError { - updateStatus(it.description) - updateAlert( - Alert(it.description ?: "") { - updateAlert(null) - } + updateToast( + ToastInfo( + type = ToastType.Error, + message = it.description ?: getString(Res.string.unknown_error), + onClose = { updateToast(null) } + ) ) } @@ -503,83 +513,77 @@ class AnalyzeViewModel( private fun saveReport() { screenModelScope.launch { - val yes = getString(Res.string.yes) - val no = getString(Res.string.no) - val likelyCorrect = getString(Res.string.likely_correct) - val likelyIncorrect = getString(Res.string.likely_incorrect) - val reviewNeeded = getString(Res.string.review_needed) - val name = getString(Res.string.name) - - val header = StringBuilder() - header.append("word,book,chapter,verse,") - _state.value.models.forEachIndexed { i, model -> - header.append("model${i+1}") - header.append(",") + _state.update { + it.copy( + progress = Progress( + -1f, + getString(Res.string.generating_report) + ) + ) } - header.append("consensus,") - header.append("correct\n") - - val words = _state.value.singletons.joinToString("\n") { singleton -> - val builder = StringBuilder() - builder.append(singleton.word) - builder.append(",") - builder.append(singleton.ref.bookName) - builder.append(" (${singleton.ref.bookSlug})") - builder.append(",") - builder.append(singleton.ref.chapter) - builder.append(",") - builder.append(singleton.ref.number) - builder.append(",") - - singleton.result?.models?.forEach { - val status = when (it.status) { - WordStatus.INCORRECT -> likelyIncorrect - WordStatus.CORRECT -> likelyCorrect - WordStatus.NAME -> name - else -> "" - } - - builder.append("\"") - builder.append(it.model) - builder.append("\n") - builder.append(status) - builder.append("\"") - builder.append(",") - } - val correct = when (singleton.correct) { - true -> yes - false -> no - else -> "" - } - - val consensus = when (singleton.result?.consensus) { - Consensus.LIKELY_CORRECT -> likelyCorrect - Consensus.LIKELY_INCORRECT -> likelyIncorrect - Consensus.NEEDS_REVIEW -> reviewNeeded - Consensus.NAME -> name - null -> "" + withContext(Dispatchers.Default) { + watApi.getBatchReport( + ietfCode, + resourceType, + user.token.accessToken + ).onSuccess { + saveFile( + bytes = it, + filename = "report", + extension = "csv" + ) + }.onError { + println(it) } + } - builder.append(consensus) - builder.append(",") - builder.append(correct) - builder.toString() + _state.update { + it.copy( + progress = null, + toast = ToastInfo( + type = ToastType.Success, + message = getString(Res.string.report_saved), + onClose = { updateToast(null) } + ) + ) } + } + } - val report = header.toString() + words + private fun resetReview(batchId: String) { + screenModelScope.launch { + _state.update { + it.copy( + progress = Progress( + -1f, + getString(Res.string.resetting_review_progress) + ) + ) + } - val saved = FileKit.saveFile( - baseName = "report_${language.ietfCode}_${resourceType}", - extension = "csv", - bytes = report.encodeToByteArray() - ) + val result = withContext(Dispatchers.Default) { + var message = "" + watApi.resetReviewProgress( + batchId, + user.token.accessToken + ).onSuccess { + fetchBatch(loop = false) + message = getString(Res.string.reset_review_progress_success) + }.onError { + message = it.description ?: "Unknown error" + } + message + } - if (saved != null) { - updateAlert( - Alert(getString(Res.string.report_saved)) { - updateAlert(null) - } + _state.update { + it.copy( + progress = null, + toast = ToastInfo( + type = ToastType.Success, + message = result, + onClose = { updateToast(null) } + ) ) } } @@ -609,9 +613,9 @@ class AnalyzeViewModel( } } - private fun updateAlert(alert: Alert?) { + private fun updateToast(toast: ToastInfo?) { _state.update { - it.copy(alert = alert) + it.copy(toast = toast) } } @@ -621,6 +625,7 @@ class AnalyzeViewModel( } } + @OptIn(ExperimentalTime::class) private suspend fun updateStatus(details: Any?) { val status = details?.let { val time = Clock.System.now().toLocalDateTime( diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/HomeScreen.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/HomeScreen.kt index cd03354..7f0c209 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/HomeScreen.kt @@ -1,5 +1,10 @@ package org.bibletranslationtools.wat.ui +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -13,12 +18,12 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.filled.Add import androidx.compose.material3.Button import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -37,18 +42,17 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.koin.koinScreenModel -import cafe.adriel.voyager.navigator.LocalNavigator -import cafe.adriel.voyager.navigator.currentOrThrow import dev.burnoo.compose.remembersetting.rememberStringSettingOrNull import org.bibletranslationtools.wat.data.LanguageInfo import org.bibletranslationtools.wat.domain.Settings import org.bibletranslationtools.wat.domain.User +import org.bibletranslationtools.wat.navigation.UrlManager import org.bibletranslationtools.wat.ui.control.ExtraAction -import org.bibletranslationtools.wat.ui.control.PageType +import org.bibletranslationtools.wat.ui.control.MessageToast import org.bibletranslationtools.wat.ui.control.TopNavigationBar -import org.bibletranslationtools.wat.ui.dialogs.AlertDialog import org.bibletranslationtools.wat.ui.dialogs.LanguagesDialog import org.bibletranslationtools.wat.ui.dialogs.ProgressDialog +import org.bibletranslationtools.wat.ui.theme.getFontFamilyForText import org.jetbrains.compose.resources.stringResource import org.koin.core.parameter.parametersOf import wordanalysistool.composeapp.generated.resources.Res @@ -67,15 +71,11 @@ class HomeScreen(private val user: User) : Screen { val viewModel = koinScreenModel { parametersOf(user) } - val navigator = LocalNavigator.currentOrThrow - val state by viewModel.state.collectAsStateWithLifecycle() - val event by viewModel.event.collectAsStateWithLifecycle(HomeEvent.Idle) var accessToken by rememberStringSettingOrNull(Settings.ACCESS_TOKEN.name) var selectedHeartLanguage by remember { mutableStateOf(null) } - var selectedResourceType by remember { mutableStateOf(null) } var showLanguagesDialog by remember { mutableStateOf(false) } LaunchedEffect(selectedHeartLanguage) { @@ -84,32 +84,17 @@ class HomeScreen(private val user: User) : Screen { } } - LaunchedEffect(event) { - when (event) { - is HomeEvent.VersesLoaded -> { - val language = (event as HomeEvent.VersesLoaded).language - val resourceType = (event as HomeEvent.VersesLoaded).resourceType - navigator.push( - AnalyzeScreen(language, resourceType, state.verses, user) - ) - viewModel.onEvent(HomeEvent.OnBeforeNavigate) - } - else -> Unit - } - } - Scaffold( topBar = { TopNavigationBar( title = "", user = user, - page = PageType.HOME, ExtraAction( title = stringResource(Res.string.logout), icon = Icons.AutoMirrored.Filled.Logout, onClick = { accessToken = null - navigator.pop() + UrlManager.replaceAll(LoginScreen()) } ) ) @@ -145,8 +130,12 @@ class HomeScreen(private val user: User) : Screen { Text(stringResource(Res.string.creator)) } Surface( - shape = RoundedCornerShape(8.dp), - modifier = Modifier.shadow(4.dp, RoundedCornerShape(8.dp)) + shape = MaterialTheme.shapes.medium, + modifier = Modifier + .shadow( + elevation = 4.dp, + shape = MaterialTheme.shapes.medium + ) ) { LazyColumn( modifier = Modifier.fillMaxWidth() @@ -157,12 +146,14 @@ class HomeScreen(private val user: User) : Screen { verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() .height(50.dp) - .clip(RoundedCornerShape(8.dp)) + .clip(MaterialTheme.shapes.medium) .clickable { - viewModel.onEvent( - HomeEvent.FetchUsfm( - language = batch.language, - resourceType = batch.resourceType + UrlManager.push( + ReviewScreen( + ietfCode = batch.language.ietfCode, + resourceType = batch.resourceType, + user = user, + batchId = batch.id ) ) } @@ -170,7 +161,10 @@ class HomeScreen(private val user: User) : Screen { ) { Text( text = batch.language.toString(), - modifier = Modifier.weight(0.34f) + modifier = Modifier.weight(0.34f), + fontFamily = getFontFamilyForText( + batch.language.toString() + ) ) Text( text = batch.resourceType, @@ -187,6 +181,23 @@ class HomeScreen(private val user: User) : Screen { } } } + + AnimatedVisibility( + visible = state.toast != null, + enter = slideInHorizontally(initialOffsetX = { it }) + fadeIn(), + exit = slideOutHorizontally(targetOffsetX = { it }) + fadeOut(), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp, bottom = 64.dp) + ) { + state.toast?.let { data -> + MessageToast( + type = data.type, + message = data.message, + onDismiss = data.onClose + ) + } + } } if (showLanguagesDialog) { @@ -195,20 +206,18 @@ class HomeScreen(private val user: User) : Screen { resourceTypes = state.resourceTypes, onLanguageSelected = { selectedHeartLanguage = it }, onResourceTypeSelected = { language, resourceType -> - selectedResourceType = resourceType - viewModel.onEvent(HomeEvent.FetchUsfm(language, resourceType)) + UrlManager.push( + ReviewScreen( + ietfCode = language.ietfCode, + resourceType = resourceType, + user = user + ) + ) }, onDismiss = { showLanguagesDialog = false } ) } - state.alert?.let { - AlertDialog( - message = it.message, - onDismiss = it.onClosed - ) - } - state.progress?.let { ProgressDialog(it) } diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/HomeViewModel.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/HomeViewModel.kt index f3d669a..05659c6 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/HomeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/HomeViewModel.kt @@ -4,48 +4,41 @@ import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.bibletranslationtools.wat.data.Alert -import org.bibletranslationtools.wat.data.ContentInfo import org.bibletranslationtools.wat.data.Direction import org.bibletranslationtools.wat.data.LanguageInfo import org.bibletranslationtools.wat.data.Progress -import org.bibletranslationtools.wat.data.Verse +import org.bibletranslationtools.wat.data.ToastInfo +import org.bibletranslationtools.wat.data.ToastType import org.bibletranslationtools.wat.domain.BielGraphQlApi -import org.bibletranslationtools.wat.domain.DownloadUsfm import org.bibletranslationtools.wat.domain.User -import org.bibletranslationtools.wat.domain.UsfmBookSource import org.bibletranslationtools.wat.domain.WatApi import org.bibletranslationtools.wat.http.onError import org.bibletranslationtools.wat.http.onSuccess import org.jetbrains.compose.resources.getString import wordanalysistool.composeapp.generated.resources.Res -import wordanalysistool.composeapp.generated.resources.downloading_usfm import wordanalysistool.composeapp.generated.resources.fetching_batches import wordanalysistool.composeapp.generated.resources.fetching_heart_languages import wordanalysistool.composeapp.generated.resources.fetching_resource_types -import wordanalysistool.composeapp.generated.resources.preparing_for_analysis import wordanalysistool.composeapp.generated.resources.unknown_error data class BatchItem( + val id: String, val language: LanguageInfo, val resourceType: String, val username: String ) data class HomeState( - val alert: Alert? = null, + val toast: ToastInfo? = null, val progress: Progress? = null, - val verses: List = emptyList(), val heartLanguages: List = emptyList(), val resourceTypes: List = emptyList(), val batches: List = emptyList() @@ -54,17 +47,12 @@ data class HomeState( sealed class HomeEvent { data object Idle: HomeEvent() data class FetchResourceTypes(val ietfCode: String): HomeEvent() - data class FetchUsfm(val language: LanguageInfo, val resourceType: String): HomeEvent() - data class VersesLoaded(val language: LanguageInfo, val resourceType: String): HomeEvent() - data object OnBeforeNavigate: HomeEvent() } class HomeViewModel( + private val user: User, private val bielGraphQlApi: BielGraphQlApi, - private val downloadUsfm: DownloadUsfm, - private val usfmBookSource: UsfmBookSource, - private val watApi: WatApi, - private val user: User + private val watApi: WatApi ) : ScreenModel { private var _state = MutableStateFlow(HomeState()) @@ -77,20 +65,22 @@ class HomeViewModel( ) private val _event: Channel = Channel() - val event = _event.receiveAsFlow() fun onEvent(event: HomeEvent) { when (event) { is HomeEvent.FetchResourceTypes -> fetchResourceTypes(event.ietfCode) - is HomeEvent.FetchUsfm -> fetchUsfm(event.language, event.resourceType) - is HomeEvent.OnBeforeNavigate -> onBeforeNavigate() else -> resetChannel() } } private fun fetchHeartLanguages() { screenModelScope.launch { - updateProgress(Progress(0f, getString(Res.string.fetching_heart_languages))) + updateProgress( + Progress( + value = 0f, + message = getString(Res.string.fetching_heart_languages) + ) + ) // TODO Remove debug code val en = LanguageInfo("en", "English", "English", Direction.LTR) val ru = LanguageInfo("ru", "Русский", "Russian", Direction.LTR) @@ -105,7 +95,7 @@ class HomeViewModel( screenModelScope.launch { updateProgress(Progress(0f, getString(Res.string.fetching_resource_types))) // TODO Remove debug code - val resourceTypes = if (ietfCode in listOf("en","ru")) { + val resourceTypes = if (ietfCode in listOf("en","ru","pap-AW-papiamento","bah")) { listOf("ulb") } else { bielGraphQlApi.getUsfmForHeartLanguage(ietfCode).keys.toList() @@ -115,9 +105,12 @@ class HomeViewModel( } } - private fun fetchBatchesInProgress() { - screenModelScope.launch { - updateProgress(Progress(0f, getString(Res.string.fetching_batches))) + private suspend fun fetchBatchesInProgress() { + updateProgress(Progress( + 0f, + getString(Res.string.fetching_batches)) + ) + withContext(Dispatchers.Default) { watApi.getBatchesInProgress(user.token.accessToken) .onSuccess { batches -> val batchItems = batches.map { batch -> @@ -130,6 +123,7 @@ class HomeViewModel( direction = Direction.LTR ) BatchItem( + id = batch.id, language = language, resourceType = batch.resourceType, username = batch.creator.username @@ -138,80 +132,16 @@ class HomeViewModel( updateBatches(batchItems) } .onError { - updateAlert( - Alert(it.description ?: getString(Res.string.unknown_error)) { - updateAlert(null) - } + updateToast( + ToastInfo( + type = ToastType.Error, + message = it.description ?: getString(Res.string.unknown_error), + onClose = { updateToast(null) } + ) ) } - updateProgress(null) } - } - - private fun fetchUsfm( - language: LanguageInfo, - resourceType: String - ) { - screenModelScope.launch { - updateProgress(Progress(0f, getString(Res.string.downloading_usfm))) - - // TODO Remove debug code - val books = when(language.ietfCode) { - "en" -> listOf(ContentInfo("", "Jude", "jud", null)) - "ru" ->listOf(ContentInfo("", "Послание Иуды", "jud", null)) - else -> bielGraphQlApi.getBooksForTranslation(language.ietfCode, resourceType) - } - - val totalBooks = books.size - val allVerses = mutableListOf() - - withContext(Dispatchers.Default) { - books.forEachIndexed { index, book -> - book.url?.let { url -> - val currentProgress = (index+1)/totalBooks.toFloat() - - // TODO Remove debug code - if (language.ietfCode !in listOf("en","ru")) { - val response = downloadUsfm(url) - - response.onSuccess { bytes -> - allVerses.addAll(usfmBookSource.parse(bytes.decodeToString())) - }.onError { err -> - updateAlert( - Alert(err.description ?: getString(Res.string.unknown_error)) { - updateAlert(null) - } - ) - allVerses.clear() - return@withContext - } - } else { - if (language.ietfCode == "en") { - allVerses.addAll(getEnglishFakeVerses()) - } else { - allVerses.addAll(getRussianFakeVerses()) - } - } - - updateProgress( - Progress(currentProgress, getString(Res.string.downloading_usfm)) - ) - } - } - } - - updateVerses(allVerses) - - updateProgress(Progress(0f, getString(Res.string.preparing_for_analysis))) - delay(1000) - updateProgress(null) - - _event.send(HomeEvent.VersesLoaded(language, resourceType)) - } - } - - private fun onBeforeNavigate() { - updateVerses(emptyList()) + updateProgress(null) } private fun updateHeartLanguages(heartLanguages: List) { @@ -232,21 +162,15 @@ class HomeViewModel( } } - private fun updateVerses(verses: List) { - _state.update { - it.copy(verses = verses) - } - } - private fun updateProgress(progress: Progress?) { _state.update { it.copy(progress = progress) } } - private fun updateAlert(alert: Alert?) { + private fun updateToast(toast: ToastInfo?) { _state.update { - it.copy(alert = alert) + it.copy(toast = toast) } } @@ -255,135 +179,4 @@ class HomeViewModel( _event.send(HomeEvent.Idle) } } - - // TODO Remove debug code - private suspend fun getEnglishFakeVerses(): List { - val usfm = """ - \id JUD Unlocked Literal Bible - \ide UTF-8 - \h Jude - \toc1 The Letter of Jude - \toc2 Jude - \toc3 Jud - \mt Jude - - \s5 - \c 1 - \p - \v 1 Jude, a servant of Jesus Christ and brother of Jamess, to those who are called, beloved in God the Father, and kept for Jesus Christ: - \p - \v 2 May mercy and peace and love be multiplied to you. - - \s5 - \p - \v 3 Beloved, while I was making every efort to write to you about our common salvation, I had to write to you to exhort you to struggle earnestly for the faith that was entrusted once for all to God's holy people. - \v 4 For certain men have slipped in secretly among you. These men were marked out for condemnation. They are ungodly men who have changed the grace of our God into sensuality, and who deny our only Master and Lord, Jesus Christ. - - \s5 - \p - \v 5 Now I wish to remind you—although once you fully knew it—that the Lord saved a people out of the land of Egypt, but that afterward he destroyed those who did not believe. - \v 6 Also, angels who did not keep to their own position of authority, but who left their proper dwelling place—God has kept them in everlasting chains, in utter darkness, for the judgment on the great day. - - \s5 - \v 7 So also Sodom and Gomorrah and the cities around them gave themselves over to sexual immorality and perverse sexual acts. They serve as an example of those who suffer the punishment of eternal fire. - \v 8 Yet in the same way, these dreamers also defile their bodies. They reject authority and they slander the glorious ones. - - \s5 - \v 9 But even Michael the archangel, when he was arguing with the devil and disputing with him about the body of Moses, did not dare to bring a slanderous judgment against him, but he said, "May the Lord rebuke you!" - \v 10 But these people insult whatever they do not understand; and what they do understand naturally, like unreasoning animals, these are the very things that destroy them. - \v 11 Woe to them! For they have walked in the way of Cain and have plunged into Balam's error for profit. They have perished in Korash's rebellion. - - \s5 - \v 12 These people are dangerous reefs at your love feasts, feasting with you fearlessly—shepherds who only feed themselves. They are clouds without rain, carried along by winds; autumn trees without fruit—twice dead, uprooted. - \v 13 They are violent waves in the sea, foaming up their shame; wandering stars, for whom the gloom of complete darkness has been reserved forever. - - \s5 - \v 14 Enoch, the seventh from Adam, prephesied about them, saying, "Look! The Lord is coming with thousands and thousands of his holy ones. - \v 15 He is coming to execute judgment on everyone. He is coming to convict all the ungodly of all the works they have done in an ungodly way, and of all the bitter words that ungodly sinners have spoken against him." - \v 16 These are grumblers, complainers, following their evil desires. Their mouths speak loud boasts, flattering others for profit. - - \s5 - \p - \v 17 But you, beloved, remember hte words that were spoken in the past by the apostles of our Lord Jesus Christ. - \v 18 They said to yuo, "In the last time there will be mockers who will follow their own ungodly desires." - \v 19 It is these who cause divisions; they are worldly, and they do not have the Spirit. - - \s5 - \v 20 But you, beloved, build yourselves up in your most holy faith, and pray in the Holy Spirit. - \v 21 Keep yourselves in God's love, and wait for the mercy of our Lord Jesus Christ that brings you eternal life. - - \s5 - \v 22 Be merciful to those who doubt. - \v 23 Save others by snatching them out of teh fire; to others show mercy with fear, hating even the garment defiled by the flesh. - - \s5 - \p - \v 24 Now to the one who is able to keep you from stumbling and to cause you to stand before his glorious presence without blemish and with great joy, - \v 25 to the only God our Savior through Jesus Christ our Lord, be glory, majesty, dominion, and authority, before all time, now, and forever. Amen. - """.trimIndent() - return usfmBookSource.parse(usfm) - } - - // TODO Remove debug code - private suspend fun getRussianFakeVerses(): List { - val usfm = """ - \id JUD - \ide UTF-8 - \h Иуды - \toc1 Иуды - \toc2 Иуды - \toc3 jud - \mt Иуды - - \s5 - \c 1 - \p - \v 1 Иуда, раб Иисуса Христа, брат Иакова, призванным, которые освящены Богом Отцом и сохранены Иисусом Христом: - \v 2 милость для вас, мир и любовь пусть умножатся. - - \s5 - \v 3 Возлюбленные! Имея всё усердие писать вам об общем спасении, я счёл нужным написать вам наставление: сражаться за веру, однажды переданную святым. - \v 4 Потому что вкрадываются некоторые люди, прежде предназначенные к осуждению, нечестивые, обращающие благодать Бога нашего в повод к разврату и отвергающие единого Правителя Бога и Господа нашего Иисуса Христа. - - \s5 - \v 5 Я хочу напомнить вам, знающим это, что Господь, избавив народ из египетской земли, затем погубил неверовавших - \v 6 и ангелов, которые не сохранили достоинства, но покинули своё жилище, сохраняет в вечных оковах, во тьме, на суд великого дня. - - \s5 - \v 7 Как Содон и Гомора, приведённые в пример, и окрестные города, подобно им, предавались разврату, блуду, ходили за другой плотью и подверглись наказанию в огне на веки – - \v 8 так точно будет и с этими метателями, которые оскверняют плоть, отвергают госпотство и бесчестят славу. - - \s5 - \v 9 Михаил Архангел, когда спорил с дьяволом о теле Моисея, не осмелился вынести осуждающего приговора, но сказал: «Пусть запретит тебе Господь». - \v 10 А эти злословят то, чего не знают. Что же знают по природе своей, как неразумные животные - этим уничтожают себя. - \v 11 Горе им, потому что идут путем Каина, идут за плату, заблуждаясь как Валаам, и в раздоре погибают, как Корей. - - \s5 - \v 12 Они и бывают соблазном на ваших вечерях любви. Обедая с вами, без страха откармливают себя. Они как безводные облака, носимые ветром, как осенние деревья - бесплодные, дважды умершие, вырванные с корнем, - \v 13 как свирепые морские волны, пенящиеся своим позором, как скитающиеся звезды, для которых сохраняется мрак тьмы навеки. - - \s5 - \v 14 О них произнёс пророчество и Енох, седьмой от Алама, говоря: «Вот, идёт Господь с десятью тысячами святых Его - \v 15 произвести суд над всеми и обличить всех нечестивых между ними, во всех делах, в которых они поступали нечестиво, и во всех жестоких словах, которые произносили на Него нечестивые грешники». - \v 16 Они ничем не довольные ворчуны, поступающие по своим прихотям. Открывая свой рот надменно, льстят для своей выгоды. - - \s5 - \v 17 Но вы, возлюбленные, помните слова прежде сказанные через Апостолов Господа нашего Иисуса Христа. - \v 18 Они говорили вам, что в последнее время появятся насмешники, поступающие по своим греховным желаниям. - \v 19 Они, отделяюшие себя [от единства веры], душевные, духа не имеющие. - - \s5 - \v 20 А вы, возлюбленные, утверждая себя на святейшей вере вашей, молясь в Духе Святом, - \v 21 храните себя в любви Божьей, ожидая милость Господа нашего Иисуса Христа для вечной жизни. - - \s5 - \v 22 И к одним будьте милостивы, с рассмотрением, - \v 23 а других в страхе спасайте, выхватывая из огня, обличайте же со страхом, брезгуя даже одеждой, которая осквернена плотью. - - \s5 - \v 24 Тому, кто может сохранить вас от падения и поставить перед Своей славой безупречными в радости, - \v 25 Единому Премудрому Богу, нашему Спасителю через Иисуса Христа, нашего Господа, слава и величие, сила и власть прежде всех веков, теперь и в вечности. Аминь. - """.trimIndent() - return usfmBookSource.parse(usfm) - } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/LoginScreen.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/LoginScreen.kt index 3356b3c..47c38a5 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/LoginScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/LoginScreen.kt @@ -1,7 +1,13 @@ package org.bibletranslationtools.wat.ui +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -12,6 +18,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.koin.koinScreenModel @@ -20,13 +27,14 @@ import cafe.adriel.voyager.navigator.currentOrThrow import dev.burnoo.compose.remembersetting.rememberStringSettingOrNull import org.bibletranslationtools.wat.domain.Settings import org.bibletranslationtools.wat.domain.Token -import org.bibletranslationtools.wat.ui.dialogs.AlertDialog +import org.bibletranslationtools.wat.navigation.UrlManager +import org.bibletranslationtools.wat.ui.control.MessageToast import org.jetbrains.compose.resources.stringResource import wordanalysistool.composeapp.generated.resources.Res import wordanalysistool.composeapp.generated.resources.login import wordanalysistool.composeapp.generated.resources.login_progress -class LoginScreen : Screen { +class LoginScreen(private val initialPath: String? = null) : Screen { @Composable override fun Content() { @@ -60,10 +68,29 @@ class LoginScreen : Screen { } LaunchedEffect(state.user) { - state.user?.let { - accessToken = it.token.accessToken + state.user?.let { user -> + accessToken = user.token.accessToken viewModel.onEvent(LoginEvent.OnBeforeNavigate) - navigator.push(HomeScreen(it)) + + val (ietfCode, resourceType) = initialPath?.let { path -> + val parts = path.replace("/", "").split("_") + if (parts.size == 2) { + parts[0] to parts[1] + } else { + null + } + } ?: (null to null) + + if (ietfCode != null && resourceType != null) { + UrlManager.replaceAll( + listOf( + HomeScreen(user), + ReviewScreen(ietfCode, resourceType, user) + ) + ) + } else { + navigator.replaceAll(HomeScreen(user)) + } } } @@ -83,13 +110,23 @@ class LoginScreen : Screen { Text(stringResource(Res.string.login)) } } - } - state.alert?.let { - AlertDialog( - message = it.message, - onDismiss = it.onClosed - ) + AnimatedVisibility( + visible = state.toast != null, + enter = slideInHorizontally(initialOffsetX = { it }) + fadeIn(), + exit = slideOutHorizontally(targetOffsetX = { it }) + fadeOut(), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp, bottom = 64.dp) + ) { + state.toast?.let { data -> + MessageToast( + type = data.type, + message = data.message, + onDismiss = data.onClose + ) + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/LoginViewModel.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/LoginViewModel.kt index 9aa76e3..6e841b9 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/LoginViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/LoginViewModel.kt @@ -15,7 +15,8 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.bibletranslationtools.wat.data.Alert +import org.bibletranslationtools.wat.data.ToastInfo +import org.bibletranslationtools.wat.data.ToastType import org.bibletranslationtools.wat.domain.Token import org.bibletranslationtools.wat.domain.User import org.bibletranslationtools.wat.domain.WatApi @@ -29,7 +30,7 @@ import wordanalysistool.composeapp.generated.resources.unknown_error data class LoginState( val user: User? = null, - val alert: Alert? = null, + val toast: ToastInfo? = null, val progress: Boolean = false ) @@ -66,7 +67,7 @@ class LoginViewModel( is LoginEvent.FetchToken -> fetchToken() is LoginEvent.UpdateUser -> tokenToUser(event.token) is LoginEvent.OnBeforeNavigate -> onBeforeNavigate() - is LoginEvent.ClearAlert -> updateAlert(null) + is LoginEvent.ClearAlert -> updateToast(null) else -> resetChannel() } } @@ -78,10 +79,12 @@ class LoginViewModel( _event.send(LoginEvent.OnAuthOpen(it)) } .onError { - updateAlert( - Alert(it.description ?: getString(Res.string.unknown_error)) { - updateAlert(null) - } + updateToast( + ToastInfo( + type = ToastType.Error, + message = it.description ?: getString(Res.string.unknown_error), + onClose = { updateToast(null) } + ) ) } } @@ -115,11 +118,13 @@ class LoginViewModel( .onSuccess { try { updateUser(User.fromToken(token)) - } catch (e: Exception) { - updateAlert( - Alert(getString(Res.string.token_invalid)) { - updateAlert(null) - } + } catch (_: Exception) { + updateToast( + ToastInfo( + type = ToastType.Error, + message = getString(Res.string.token_invalid), + onClose = { updateToast(null) } + ) ) _event.send(LoginEvent.TokenInvalid) } @@ -127,17 +132,21 @@ class LoginViewModel( .onError { when (it.type) { ErrorType.Unauthorized -> { - updateAlert( - Alert(getString(Res.string.token_invalid)) { - updateAlert(null) - } + updateToast( + ToastInfo( + type = ToastType.Error, + message = getString(Res.string.token_invalid), + onClose = { updateToast(null) } + ) ) _event.send(LoginEvent.TokenInvalid) } - else -> updateAlert( - Alert(it.description ?: getString(Res.string.unknown_error)) { - updateAlert(null) - } + else -> updateToast( + ToastInfo( + type = ToastType.Error, + message = it.description ?: getString(Res.string.unknown_error), + onClose = { updateToast(null) } + ) ) } } @@ -152,9 +161,9 @@ class LoginViewModel( } } - private fun updateAlert(alert: Alert?) { + private fun updateToast(toast: ToastInfo?) { _state.update { - it.copy(alert = alert) + it.copy(toast = toast) } } @@ -173,7 +182,7 @@ class LoginViewModel( private fun onBeforeNavigate() { fetchJob?.cancel() updateUser(null) - updateAlert(null) + updateToast(null) updateProgress(false) } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/ReviewScreen.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/ReviewScreen.kt new file mode 100644 index 0000000..25bcf46 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/ReviewScreen.kt @@ -0,0 +1,475 @@ +package org.bibletranslationtools.wat.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ChevronLeft +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.koin.koinScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import dev.burnoo.compose.remembersetting.rememberStringSettingOrNull +import org.bibletranslationtools.wat.domain.Settings +import org.bibletranslationtools.wat.domain.User +import org.bibletranslationtools.wat.navigation.UrlManager +import org.bibletranslationtools.wat.ui.control.CustomTextButton +import org.bibletranslationtools.wat.ui.control.MessageToast +import org.bibletranslationtools.wat.ui.control.PaginationControls +import org.bibletranslationtools.wat.ui.control.SingletonRow +import org.bibletranslationtools.wat.ui.dialogs.ProgressDialog +import org.bibletranslationtools.wat.ui.theme.getFontFamilyForText +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.koin.core.parameter.parametersOf +import wordanalysistool.composeapp.generated.resources.Res +import wordanalysistool.composeapp.generated.resources.admin +import wordanalysistool.composeapp.generated.resources.app_name +import wordanalysistool.composeapp.generated.resources.back +import wordanalysistool.composeapp.generated.resources.complete_success +import wordanalysistool.composeapp.generated.resources.complete_success_description +import wordanalysistool.composeapp.generated.resources.flag +import wordanalysistool.composeapp.generated.resources.flag_incorrect_words +import wordanalysistool.composeapp.generated.resources.home +import wordanalysistool.composeapp.generated.resources.loading +import wordanalysistool.composeapp.generated.resources.return_home +import wordanalysistool.composeapp.generated.resources.settings +import wordanalysistool.composeapp.generated.resources.sign_out + +class ReviewScreen( + val ietfCode: String, + val resourceType: String, + private val user: User, + private val batchId: String? = null +) : Screen { + + @Composable + override fun Content() { + val viewModel = koinScreenModel { + parametersOf(ietfCode, resourceType, user, batchId) + } + + val navigator = LocalNavigator.currentOrThrow + + val state by viewModel.state.collectAsStateWithLifecycle() + val event by viewModel.event.collectAsStateWithLifecycle(AnalyzeEvent.Idle) + + var accessToken by rememberStringSettingOrNull(Settings.ACCESS_TOKEN.name) + + LaunchedEffect(event) { + when (event) { + is ReviewEvent.Logout -> { + accessToken = null + navigator.popUntilRoot() + } + else -> Unit + } + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.surface + ) { paddingValues -> + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + .padding(paddingValues) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth(0.7f) + .fillMaxHeight() + .padding(16.dp) + ) { + Box( + modifier = Modifier + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = MaterialTheme.shapes.medium + ) + .weight(0.3f) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = stringResource(Res.string.app_name), + fontSize = 26.sp, + fontWeight = FontWeight.W500, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = state.language?.name ?: "", + style = LocalTextStyle.current.copy( + textDirection = TextDirection.ContentOrLtr, + fontSize = 22.sp, + fontWeight = FontWeight.W600, + fontFamily = getFontFamilyForText(state.language?.name ?: ""), + color = MaterialTheme.colorScheme.onSurface + ), + modifier = Modifier.fillMaxWidth() + ) + + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy( + space = 4.dp, + alignment = Alignment.Bottom + ), + modifier = Modifier.weight(1f) + ) { + CustomTextButton( + onClick = UrlManager::pop, + icon = Icons.Default.Home, + text = stringResource(Res.string.home) + ) + CustomTextButton( + onClick = { + navigator.push(SettingsScreen(user)) + }, + icon = Icons.Default.Settings, + text = stringResource(Res.string.settings) + ) + if (user.admin) { + CustomTextButton( + onClick = { + navigator.push(AnalyzeScreen( + ietfCode = ietfCode, + resourceType = resourceType, + verses = state.verses, + user = user + )) + }, + icon = painterResource(Res.drawable.admin), + text = stringResource(Res.string.admin) + ) + } + CustomTextButton( + onClick = { + accessToken = null + UrlManager.replaceAll(LoginScreen()) + }, + icon = Icons.Default.Person, + text = stringResource( + Res.string.sign_out, + user.username + ) + ) + } + } + } + + Box( + modifier = Modifier + .weight(0.7f) + .fillMaxHeight(), + contentAlignment = Alignment.Center + ) { + if (state.words.isNotEmpty()) { + Column( + modifier = Modifier.fillMaxSize() + .padding(horizontal = 32.dp) + ) { + Spacer(modifier = Modifier.height(16.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + LinearProgressIndicator( + progress = { state.completeProgress }, + modifier = Modifier.weight(1f), + gapSize = 0.dp + ) + Text( + text = "${(state.completeProgress*100).toInt()}%", + ) + } + + if (state.completeProgress < 1.0) { + val placeholder = "[flag]" + val inlineContentId = "flagIconId" + val rawText = stringResource( + Res.string.flag_incorrect_words, + placeholder + ) + val text = buildAnnotatedString { + val index = rawText.indexOf(placeholder) + if (index != -1) { + append(rawText.take(index)) + appendInlineContent(inlineContentId, placeholder) + append(rawText.substring( + index + placeholder.length, + rawText.length + )) + } else { + append(rawText) + } + } + val inlineContentMap = mapOf( + inlineContentId to InlineTextContent( + Placeholder( + width = 52.sp, + height = 32.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center + ) + ) { + Box( + modifier = Modifier.fillMaxSize() + .border( + border = BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outline + ), + shape = MaterialTheme.shapes.large, + ) + .background( + color = MaterialTheme.colorScheme.surface, + shape = MaterialTheme.shapes.large + ) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + .padding(4.dp) + ) { + Icon( + painter = painterResource(Res.drawable.flag), + contentDescription = "flag", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + } + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.secondaryContainer, + border = BorderStroke( + width = 2.dp, + color = MaterialTheme.colorScheme.secondary + ) + ) { + Box( + modifier = Modifier.padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + inlineContent = inlineContentMap, + fontSize = 18.sp, + fontWeight = FontWeight.W400, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(64.dp), + modifier = Modifier.weight(1f) + ) { + items(items = state.words, key = { it.word }) { singleton -> + SingletonRow( + singleton = singleton, + enabled = !state.isLoading, + onFlagged = { + viewModel.onFlagClicked(singleton.word) + } + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + PaginationControls( + currentPage = state.currentPage, + totalPages = state.totalPages, + enabled = !state.isLoading, + onSave = viewModel::onSave, + modifier = Modifier.fillMaxWidth() + ) + } else { + Spacer(modifier = Modifier.height(16.dp)) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxSize() + ) { + Spacer(modifier = Modifier.height(1.dp)) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "competed", + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(60.dp) + ) + Text( + text = stringResource(Res.string.complete_success), + fontSize = 36.sp, + fontWeight = FontWeight.W500 + ) + Text( + text = stringResource(Res.string.complete_success_description), + fontSize = 16.sp + ) + } + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedButton( + onClick = { }, + enabled = false, + shape = MaterialTheme.shapes.small, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outline + ), + contentPadding = PaddingValues(end = 8.dp) + ) { + Icon( + imageVector = Icons.Default.ChevronLeft, + contentDescription = "back" + ) + Text(stringResource(Res.string.back)) + } + + OutlinedButton( + onClick = UrlManager::pop, + shape = MaterialTheme.shapes.small, + enabled = true, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + contentPadding = PaddingValues(horizontal = 8.dp) + ) { + Text( + text = stringResource(Res.string.return_home) + ) + } + } + } + } + } + } + + if (state.isLoading) { + Column( + verticalArrangement = Arrangement.spacedBy( + 12.dp, + Alignment.CenterVertically + ), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .size(width = 300.dp, height = 100.dp) + .background( + color = MaterialTheme.colorScheme.surface, + shape = MaterialTheme.shapes.medium + ) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = MaterialTheme.shapes.medium + ) + ) { + CircularProgressIndicator() + Text( + text = stringResource(Res.string.loading), + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } + + AnimatedVisibility( + visible = state.toast != null, + enter = slideInHorizontally(initialOffsetX = { it }) + fadeIn(), + exit = slideOutHorizontally(targetOffsetX = { it }) + fadeOut(), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp, bottom = 64.dp) + ) { + state.toast?.let { data -> + MessageToast( + type = data.type, + message = data.message, + onDismiss = data.onClose + ) + } + } + } + + state.progress?.let { + ProgressDialog(it) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/ReviewViewModel.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/ReviewViewModel.kt new file mode 100644 index 0000000..59101d1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/ReviewViewModel.kt @@ -0,0 +1,759 @@ +package org.bibletranslationtools.wat.ui + +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.bibletranslationtools.wat.data.ContentInfo +import org.bibletranslationtools.wat.data.LanguageInfo +import org.bibletranslationtools.wat.data.MutableVerseRef +import org.bibletranslationtools.wat.data.Progress +import org.bibletranslationtools.wat.data.ReviewWord +import org.bibletranslationtools.wat.data.ToastInfo +import org.bibletranslationtools.wat.data.ToastType +import org.bibletranslationtools.wat.data.VerseRef +import org.bibletranslationtools.wat.data.toVerse +import org.bibletranslationtools.wat.domain.BielGraphQlApi +import org.bibletranslationtools.wat.domain.DownloadUsfm +import org.bibletranslationtools.wat.domain.JsonLenient +import org.bibletranslationtools.wat.domain.User +import org.bibletranslationtools.wat.domain.UsfmBookSource +import org.bibletranslationtools.wat.domain.WatApi +import org.bibletranslationtools.wat.domain.WordRequest +import org.bibletranslationtools.wat.domain.WordsRequest +import org.bibletranslationtools.wat.http.ErrorType +import org.bibletranslationtools.wat.http.onError +import org.bibletranslationtools.wat.http.onSuccess +import org.bibletranslationtools.wat.platform.createFileCache +import org.bibletranslationtools.wat.ui.control.SaveDirection +import org.jetbrains.compose.resources.getString +import wordanalysistool.composeapp.generated.resources.Res +import wordanalysistool.composeapp.generated.resources.downloading_usfm +import wordanalysistool.composeapp.generated.resources.failed_download_usfm +import wordanalysistool.composeapp.generated.resources.getting_batch +import wordanalysistool.composeapp.generated.resources.getting_language +import wordanalysistool.composeapp.generated.resources.unflagged_marked_correct_message +import kotlin.math.ceil + +private const val WORDS_PAGE_SIZE = 4 + +data class ReviewState( + val isLoading: Boolean = false, + val words: List = emptyList(), + val currentPage: Int = 1, + val totalPages: Int = 0, + val completeProgress: Float = 0f, + val toast: ToastInfo? = null, + val batchId: String? = null, + val progress: Progress? = null, + val verses: VerseRef = emptyMap(), + val language: LanguageInfo? = null +) + +sealed class ReviewEvent { + data object Idle : ReviewEvent() + data object Logout : ReviewEvent() +} + +class ReviewViewModel( + private val ietfCode: String, + private val resourceType: String, + private val user: User, + private val batchId: String?, + private val watApi: WatApi, + private val bielGraphQlApi: BielGraphQlApi, + private val downloadUsfm: DownloadUsfm, + private val usfmBookSource: UsfmBookSource +) : ScreenModel { + + private var _state = MutableStateFlow(ReviewState()) + val state: StateFlow = _state + .onStart { + screenModelScope.launch { + loadLanguage(ietfCode) + + batchId?.let { id -> + _state.update { it.copy(batchId = id) } + } ?: run { + fetchBatch() + } + + fetchUsfm(ietfCode, resourceType) + } + } + .stateIn( + scope = screenModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = ReviewState() + ) + + private val _event: Channel = Channel() + val event = _event.receiveAsFlow() + + val cache = createFileCache() + + private suspend fun fetchBatch() { + _state.update { + it.copy( + progress = Progress( + -1f, + getString(Res.string.getting_batch) + ), + isLoading = true + ) + } + + var errorMessage: String? = null + watApi.getBatchStats( + ietfCode = ietfCode, + resourceType = resourceType, + accessToken = user.token.accessToken + ).onSuccess { batch -> + _state.update { it.copy(batchId = batch.id) } + }.onError { error -> + errorMessage = error.description ?: "An error occurred." + } + + if (errorMessage != null) { + _state.update { + it.copy( + isLoading = false, + progress = null + ) + } + } + } + + private suspend fun loadPage(page: Int) { + _state.update { + it.copy( + isLoading = true, + progress = null + ) + } + + withContext(Dispatchers.Default) { + var errorMessage: String? = null + watApi.getReviewPage( + ietfCode = ietfCode, + resourceType = resourceType, + page = page, + limit = WORDS_PAGE_SIZE, + accessToken = user.token.accessToken + ).onSuccess { batch -> + if (batch.details.output.isNotEmpty()) { + val completed = batch.details.progress.reviewed + val total = batch.details.progress.total + val progress = completed / total.toFloat() + val currentPage = if (page > 0) { + page + } else { + if (total in 1..completed) { + ceil(total.toFloat() / WORDS_PAGE_SIZE).toInt() + } else { + (completed / WORDS_PAGE_SIZE) + 1 + } + } + val words = batch.details.output.map { word -> + ReviewWord( + word = word.word, + ref = state.value.verses[word.ref] ?: word.ref.toVerse(), + correct = word.correct + ) + } + + _state.update { + it.copy( + words = words, + currentPage = currentPage, + totalPages = ceil(total.toFloat() / WORDS_PAGE_SIZE).toInt(), + completeProgress = progress, + isLoading = false + ) + } + } else { + errorMessage = "No words found." + } + }.onError { error -> + if (error.type == ErrorType.Unauthorized) { + _event.send(ReviewEvent.Logout) + } else { + errorMessage = error.description ?: "An error occurred" + } + } + + if (errorMessage != null) { + _state.update { state -> + state.copy( + isLoading = false, + toast = ToastInfo( + type = ToastType.Error, + message = errorMessage, + onClose = { _state.update { it.copy(toast = null) } } + ) + ) + } + } + } + } + + fun onFlagClicked(word: String) { + val updatedPagedWords = _state.value.words.map { singleton -> + if (singleton.word == word) { + val newCorrectState = if (singleton.correct == false) null else false + singleton.copy(correct = newCorrectState) + } else singleton + } + _state.update { it.copy(words = updatedPagedWords) } + } + + private suspend fun saveCurrentPage(andThen: suspend () -> Unit) { + _state.update { it.copy(isLoading = true) } + + withContext(Dispatchers.Default) { + watApi.updateWordsCorrect( + request = WordsRequest( + batchId = _state.value.batchId!!, + words = _state.value.words.map { + WordRequest(it.word, it.correct ?: true) + } + ), + accessToken = user.token.accessToken + ).onSuccess { + _state.update { state -> + state.copy( + isLoading = false, + toast = ToastInfo( + type = ToastType.Info, + message = getString(Res.string.unflagged_marked_correct_message), + onClose = { _state.update { it.copy(toast = null) } } + ) + ) + } + andThen() + }.onError { error -> + _state.update { state -> + state.copy( + isLoading = false, + toast = ToastInfo( + type = ToastType.Error, + message = error.description ?: "An error occurred", + onClose = { _state.update { it.copy(toast = null) } } + ) + ) + } + } + } + } + + fun onSave(direction: SaveDirection) { + screenModelScope.launch { + val currentPage = _state.value.currentPage + val totalPages = _state.value.totalPages + saveCurrentPage { + when { + direction == SaveDirection.NEXT && currentPage < totalPages -> { + loadPage(currentPage + 1) + } + direction == SaveDirection.PREV && currentPage > 1 -> { + loadPage(currentPage - 1) + } + else -> loadPage(currentPage) + } + } + resetChannel() + } + } + + private fun fetchUsfm(ietfCode: String, resourceType: String) { + screenModelScope.launch { + _state.update { + it.copy( + progress = Progress( + 0f, + getString(Res.string.downloading_usfm) + ) + ) + } + + // TODO Remove debug code + val books = when (ietfCode) { + "en" -> listOf( + ContentInfo( + "", + "Jude", + "jud", + null + ) + ) + + "ru" -> listOf( + ContentInfo( + "", + "Послание Иуды", + "jud", + null + ) + ) + + "pap-AW-papiamento" -> getPapiamentoBooks() + "bah" -> getBahamianBooks() + else -> bielGraphQlApi.getBooksForTranslation( + ietfCode, + resourceType + ) + } + + val totalBooks = books.size + + val (verses, error) = withContext(Dispatchers.Default) { + _state.update { + it.copy( + progress = Progress( + -1f, + getString(Res.string.getting_language) + ) + ) + } + + var error: String? = null + val allVerses: MutableVerseRef = mutableMapOf() + books.forEachIndexed { index, book -> + book.url?.let { url -> + val currentProgress = (index + 1) / totalBooks.toFloat() + + // TODO Remove debug code + when (ietfCode) { + !in listOf("en", "ru") -> { + val verses = getBookVerses(url) + if (verses != null) { + allVerses.putAll(verses) + } else { + error = getString(Res.string.failed_download_usfm) + allVerses.clear() + } + } + "en" -> allVerses.putAll(getEnglishFakeVerses()) + "ru" -> allVerses.putAll(getRussianFakeVerses()) + } + + _state.update { + it.copy( + progress = Progress( + currentProgress, + getString(Res.string.downloading_usfm) + ) + ) + } + } + } + + allVerses to error + } + + _state.update { state -> + state.copy( + verses = verses, + progress = null, + toast = error?.let { + ToastInfo( + type = ToastType.Error, + message = error, + onClose = { _state.update { it.copy(toast = null) } } + ) + } + ) + } + + loadPage(0) + } + } + + private fun loadLanguage(ietfCode: String) { + screenModelScope.launch { + _state.update { + it.copy(language = bielGraphQlApi.getLanguageInfo(ietfCode)) + } + } + } + + private suspend fun getBookVerses(url: String): VerseRef? { + val cachedBytes = cache.get(url) + return if (cachedBytes != null) { + try { + JsonLenient.decodeFromString( + cachedBytes.decodeToString() + ) + } catch (_: Exception) { + fetchAndCache(url) + } + } else { + fetchAndCache(url) + } + } + + private suspend fun fetchAndCache(url: String): VerseRef? { + return try { + var verses: VerseRef? = null + var bytes: ByteArray? = null + + // TODO Remove debug code + if (url.startsWith("http")) { + downloadUsfm(url).onSuccess { data -> + bytes = data + }.onError { error -> + println("Failed to fetch verses: ${error.description}") + } + } else { + bytes = Res.readBytes(url) + } + + bytes?.let { arr -> + verses = usfmBookSource.parse(arr.decodeToString()) + .associateBy { it.toString() } + val json = JsonLenient.encodeToString(verses) + cache.put(url, json.encodeToByteArray()) + } + verses + } catch (e: Exception) { + println("Failed to fetch verses: ${e.message}") + null + } + } + + // TODO Remove debug code + private fun getPapiamentoBooks(): List { + return listOf( + ContentInfo( + "files/papiamento/01-GEN.usfm", + "Genesis", + "gen", + null + ), + ContentInfo( + "files/papiamento/41-MAT.usfm", + "Matthew", + "mat", + null + ), + ContentInfo( + "files/papiamento/42-MRK.usfm", + "Mark", + "mrk", + null + ), + ContentInfo( + "files/papiamento/43-LUK.usfm", + "Luke", + "luk", + null + ), + ContentInfo( + "files/papiamento/44-JHN.usfm", + "John", + "jhn", + null + ), + ContentInfo( + "files/papiamento/45-ACT.usfm", + "Acts", + "act", + null + ), + ContentInfo( + "files/papiamento/46-ROM.usfm", + "Romans", + "rom", + null + ), + ContentInfo( + "files/papiamento/47-1CO.usfm", + "1 Corinthians", + "1co", + null + ), + ContentInfo( + "files/papiamento/48-2CO.usfm", + "2 Corinthians", + "2co", + null + ), + ContentInfo( + "files/papiamento/49-GAL.usfm", + "Galatians", + "gal", + null + ), + ContentInfo( + "files/papiamento/50-EPH.usfm", + "Ephesians", + "eph", + null + ), + ContentInfo( + "files/papiamento/51-PHP.usfm", + "Philippians", + "php", + null + ), + ContentInfo( + "files/papiamento/52-COL.usfm", + "Colossians", + "col", + null + ), + ContentInfo( + "files/papiamento/53-1TH.usfm", + "1 Thessalonians", + "1th", + null + ), + ContentInfo( + "files/papiamento/54-2TH.usfm", + "2 Thessalonians", + "2th", + null + ), + ContentInfo( + "files/papiamento/55-1TI.usfm", + "1 Timothy", + "1ti", + null + ), + ContentInfo( + "files/papiamento/56-2TI.usfm", + "2 Timothy", + "2ti", + null + ), + ContentInfo( + "files/papiamento/57-TIT.usfm", + "Titus", + "tit", + null + ), + ContentInfo( + "files/papiamento/58-PHM.usfm", + "Philemon", + "phm", + null + ), + ContentInfo( + "files/papiamento/59-HEB.usfm", + "Hebrews", + "heb", + null + ), + ContentInfo( + "files/papiamento/60-JAS.usfm", + "James", + "jas", + null + ), + ContentInfo( + "files/papiamento/61-1PE.usfm", + "1 Peter", + "1pe", + null + ), + ContentInfo( + "files/papiamento/62-2PE.usfm", + "2 Peter", + "2pe", + null + ), + ContentInfo( + "files/papiamento/63-1JN.usfm", + "1 John", + "1jn", + null + ), + ContentInfo( + "files/papiamento/64-2JN.usfm", + "2 John", + "2jn", + null + ), + ContentInfo( + "files/papiamento/65-3JN.usfm", + "3 John", + "3jn", + null + ), + ContentInfo( + "files/papiamento/66-JUD.usfm", + "Jude", + "jud", + null + ), + ContentInfo( + "files/papiamento/67-REV.usfm", + "Revelation", + "rev", + null + ) + ) + } + + private fun getBahamianBooks(): List { + return listOf( + ContentInfo( + "files/bah/42-MRK.usfm", + "Mark", + "mrk", + null + ), + ContentInfo( + "files/bah/55-1TI.usfm", + "1 Timothy", + "1ti", + null + ) + ) + } + + private suspend fun getEnglishFakeVerses(): VerseRef { + val usfm = """ + \id JUD Unlocked Literal Bible + \ide UTF-8 + \h Jude + \toc1 The Letter of Jude + \toc2 Jude + \toc3 Jud + \mt Jude + + \s5 + \c 1 + \p + \v 1 Jude, a servant of Jesus Christ and brozer of Jamess, to those who are called, beloved in God the Father, and kept for Jesus Christ: + \p + \v 2 May mercy and peace and love be multiplied to you. + + \s5 + \p + \v 3 Beloved, while I was making every efort to write to you about our common salvation, I had to write to you to exhort you to struggle earnestly for the faith that was entrusted once for all to God's holy people. + \v 4 For certain men have slipped in secretly among you. These men were marked out for condemnation. They are ungodly men who have changed the grace of our God into sensuality, and who deny our only Master and Lord, Jesus Christ. + + \s5 + \p + \v 5 Now I wish to remind you—althouh once you fully knew it—that the Lord saved a people out of the land of Egypt, but that afteward he destroyed those who did not believe. + \v 6 Also, angels who did not keep to their own position of authority, but who left their proper dwelling place—God has kept them in everlasting chains, in utter darkness, for the judgment on the great day. + + \s5 + \v 7 So aslo Sodom and Gomorrah and the cities around them gave themselves over to sexual immorality and perverse sexual actz. They serve as an example of those who suffer the punishment of eternal fire. + \v 8 Yet in the same way, these dreamers also defile their bodiies. They reject authority and they slander the glorious ones. + + \s5 + \v 9 But even Michael the arkangel, when he was arguin with the devil and disputing with him about the body of Moses, did not dare to bring a slanderous judgment against him, but he said, "May the Lord rebuke you!" + \v 10 But these people insult whatever they do not understand; and what they do understand naturally, like unreasoning anymals, these are the very things that destroy them. + \v 11 Woe to them! For they have walked in the way of Cain and have plunged into Balam's error for profit. They have perished in Korash's rebellion. + + \s5 + \v 12 These people are dangerous reefs at your love feasts, feasting with you fearlessly—shepherds who only feed themselves. They are clouds without rain, carried along by winds; autumn trees without fruit—twice dead, uprooted. + \v 13 They are violent waves in the sea, foaming up their shame; wandering stars, for whom the gloom of complete darkness has bean reserved forever. + + \s5 + \v 14 Enoch, the seventh from Adam, prephesied about them, saying, "Look! The Lord is coming with thousands and thousands of his holy ones. + \v 15 He is coming to execute judgment on everyone. He is coming to convict all the ungodly of all the works they have done in an ungodly way, and of all the biter words that ungodly sinners have spoken against him." + \v 16 These are grumblers, complainers, following their evil desires. Their mouths speak loud boasts, flattering others for profit. + + \s5 + \p + \v 17 But you, beloved, remember hte words that were spoken in the past by the aposles of our Lord Jesus Christ. + \v 18 They said to yuo, "In the last time there will be mockers who will follow their own ungodly desires." + \v 19 It is these who cause divisions; they are worldly, and they do not have the Spirit. + + \s5 + \v 20 But you, beloved, beeld yourselves up in your most holy faith, and pray in the Holy Spirit. + \v 21 Keep yourselves in God's love, and wait for the mercy of our Lord Jesus Christ that brins you eternal life. + + \s5 + \v 22 Be merciful to those who doubt. + \v 23 Save others by snatching them out of teh fire; to others show mercy with fear, hating even the garment defiled by the flesh. + + \s5 + \p + \v 24 Now to the one who is able to keep you from stumbling and to cause you to stand before his glorious presence without bleamish and with great joy, + \v 25 to the only God our Savior through Jesus Christ our Lord, be glory, majesty, dominion, and authority, before all time, now, and forever. Amen. + """.trimIndent() + return usfmBookSource.parse(usfm).associateBy { it.toString() } + } + + // TODO Remove debug code + private suspend fun getRussianFakeVerses(): VerseRef { + val usfm = """ + \id JUD + \ide UTF-8 + \h Иуды + \toc1 Иуды + \toc2 Иуды + \toc3 jud + \mt Иуды + + \s5 + \c 1 + \p + \v 1 Иуда, раб Иисуса Христа, брат Иакова, призванным, которые освящены Богом Отцом и сохранены Иисусом Христом: + \v 2 милость для вас, мир и любовь пусть умножатся. + + \s5 + \v 3 Возлюбленные! Имея всё усердие писать вам об общем спасении, я счёл нужным написать вам наставление: сражаться за веру, однажды переданную святым. + \v 4 Потому что вкрадываются некоторые люди, прежде предназначенные к осуждению, нечестивые, обращающие благодать Бога нашего в повод к разврату и отвергающие единого Правителя Бога и Господа нашего Иисуса Христа. + + \s5 + \v 5 Я хочу напомнить вам, знающим это, что Господь, избавив народ из египетской земли, затем погубил неверовавших + \v 6 и ангелов, которые не сохранили достоинства, но покинули своё жилище, сохраняет в вечных оковах, во тьме, на суд великого дня. + + \s5 + \v 7 Как Содон и Гомора, приведённые в пример, и окрестные города, подобно им, предавались разврату, блуду, ходили за другой плотью и подверглись наказанию в огне на веки – + \v 8 так точно будет и с этими метателями, которые оскверняют плоть, отвергают госпотство и бесчестят славу. + + \s5 + \v 9 Михаил Архангел, когда спорил с дьяволом о теле Моисея, не осмелился вынести осуждающего приговора, но сказал: «Пусть запретит тебе Господь». + \v 10 А эти злословят то, чего не знают. Что же знают по природе своей, как неразумные животные - этим уничтожают себя. + \v 11 Горе им, потому что идут путем Каина, идут за плату, заблуждаясь как Валаам, и в раздоре погибают, как Корей. + + \s5 + \v 12 Они и бывают соблазном на ваших вечерях любви. Обедая с вами, без страха откармливают себя. Они как безводные облака, носимые ветром, как осенние деревья - бесплодные, дважды умершие, вырванные с корнем, + \v 13 как свирепые морские волны, пенящиеся своим позором, как скитающиеся звезды, для которых сохраняется мрак тьмы навеки. + + \s5 + \v 14 О них произнёс пророчество и Енох, седьмой от Алама, говоря: «Вот, идёт Господь с десятью тысячами святых Его + \v 15 произвести суд над всеми и обличить всех нечестивых между ними, во всех делах, в которых они поступали нечестиво, и во всех жестоких словах, которые произносили на Него нечестивые грешники». + \v 16 Они ничем не довольные ворчуны, поступающие по своим прихотям. Открывая свой рот надменно, льстят для своей выгоды. + + \s5 + \v 17 Но вы, возлюбленные, помните слова прежде сказанные через Апостолов Господа нашего Иисуса Христа. + \v 18 Они говорили вам, что в последнее время появятся насмешники, поступающие по своим греховным желаниям. + \v 19 Они, отделяюшие себя [от единства веры], душевные, духа не имеющие. + + \s5 + \v 20 А вы, возлюбленные, утверждая себя на святейшей вере вашей, молясь в Духе Святом, + \v 21 храните себя в любви Божьей, ожидая милость Господа нашего Иисуса Христа для вечной жизни. + + \s5 + \v 22 И к одним будьте милостивы, с рассмотрением, + \v 23 а других в страхе спасайте, выхватывая из огня, обличайте же со страхом, брезгуя даже одеждой, которая осквернена плотью. + + \s5 + \v 24 Тому, кто может сохранить вас от падения и поставить перед Своей славой безупречными в радости, + \v 25 Единому Премудрому Богу, нашему Спасителю через Иисуса Христа, нашего Господа, слава и величие, сила и власть прежде всех веков, теперь и в вечности. Аминь. + """.trimIndent() + return usfmBookSource.parse(usfm).associateBy { it.toString() } + } + + private fun resetChannel() { + screenModelScope.launch { + _event.send(ReviewEvent.Idle) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/SettingsScreen.kt index cc4f4fd..abbbd86 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/SettingsScreen.kt @@ -2,21 +2,29 @@ package org.bibletranslationtools.wat.ui import ComboBox import Option +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Person import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -29,7 +37,9 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow @@ -38,7 +48,6 @@ import dev.burnoo.compose.remembersetting.rememberBooleanSetting import dev.burnoo.compose.remembersetting.rememberStringSetting import dev.burnoo.compose.remembersetting.rememberStringSettingOrNull import kotlinx.coroutines.launch -import org.bibletranslationtools.wat.domain.Fonts import org.bibletranslationtools.wat.domain.Locales import org.bibletranslationtools.wat.domain.MODELS_SIZE import org.bibletranslationtools.wat.domain.Model @@ -46,20 +55,20 @@ import org.bibletranslationtools.wat.domain.ModelStatus import org.bibletranslationtools.wat.domain.Settings import org.bibletranslationtools.wat.domain.Theme import org.bibletranslationtools.wat.domain.User -import org.bibletranslationtools.wat.ui.control.ExtraAction +import org.bibletranslationtools.wat.navigation.UrlManager +import org.bibletranslationtools.wat.ui.control.CustomTextButton import org.bibletranslationtools.wat.ui.control.MultiSelectList -import org.bibletranslationtools.wat.ui.control.PageType -import org.bibletranslationtools.wat.ui.control.TopNavigationBar import org.bibletranslationtools.wat.ui.dialogs.AlertDialog import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource import wordanalysistool.composeapp.generated.resources.Res +import wordanalysistool.composeapp.generated.resources.back import wordanalysistool.composeapp.generated.resources.color_scheme -import wordanalysistool.composeapp.generated.resources.font -import wordanalysistool.composeapp.generated.resources.logout +import wordanalysistool.composeapp.generated.resources.home import wordanalysistool.composeapp.generated.resources.models import wordanalysistool.composeapp.generated.resources.select_models_limit import wordanalysistool.composeapp.generated.resources.settings +import wordanalysistool.composeapp.generated.resources.sign_out import wordanalysistool.composeapp.generated.resources.system_language import wordanalysistool.composeapp.generated.resources.theme_dark import wordanalysistool.composeapp.generated.resources.theme_light @@ -81,9 +90,6 @@ class SettingsScreen(private val user: User) : Screen { val locale = rememberStringSetting(Settings.LOCALE.name, Locales.EN.name) val localeEnum = remember { derivedStateOf { Locales.valueOf(locale.value) } } - val font = rememberStringSetting(Settings.FONT.name, Fonts.NOTO_SANS.name) - val fontEnum = remember { derivedStateOf { Fonts.valueOf(font.value) } } - val navigator = LocalNavigator.currentOrThrow var alert by remember { mutableStateOf(null) } @@ -99,160 +105,202 @@ class SettingsScreen(private val user: User) : Screen { }.toMutableStateList() val models = remember { modelsState } + var isModelsExpanded by remember { mutableStateOf(false) } + var apostropheIsSeparator by rememberBooleanSetting( Settings.APOSTROPHE_IS_SEPARATOR.name, true ) Scaffold( - topBar = { - TopNavigationBar( - title = stringResource(Res.string.settings), - user = user, - page = PageType.SETTINGS, - ExtraAction( - title = stringResource(Res.string.logout), - icon = Icons.AutoMirrored.Filled.Logout, - onClick = { - accessToken = null - navigator.popUntilRoot() - } - ) - ) - } + containerColor = MaterialTheme.colorScheme.surface, ) { paddingValues -> Box( modifier = Modifier.fillMaxSize() - .verticalScroll(rememberScrollState()) .padding(paddingValues) ) { - Column( - verticalArrangement = Arrangement.spacedBy(20.dp), + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxSize() - .padding(20.dp) + .padding(16.dp) + .padding(bottom = 32.dp) ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() + Box( + modifier = Modifier + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = MaterialTheme.shapes.medium + ) + .weight(0.3f) ) { - Text( - text = stringResource(Res.string.color_scheme), - modifier = Modifier.weight(0.5f) - ) - Spacer(modifier = Modifier.width(16.dp)) - ComboBox( - value = themeEnum.value, - options = Theme.entries.map(::Option), - onOptionSelected = { theme.value = it.name }, - valueConverter = { value -> - when (value) { - Theme.LIGHT -> lightThemeStr - Theme.DARK -> darkThemeStr - else -> systemThemeStr - } - }, - modifier = Modifier.weight(0.5f) - ) - } + Column( + modifier = Modifier.fillMaxWidth() + .padding(16.dp) + ) { + CustomTextButton( + onClick = navigator::pop, + icon = Icons.AutoMirrored.Filled.ArrowBack, + text = stringResource(Res.string.back), + modifier = Modifier.align(Alignment.End) + ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = stringResource(Res.string.system_language), - modifier = Modifier.weight(0.5f) - ) - Spacer(modifier = Modifier.width(16.dp)) - ComboBox( - value = localeEnum.value, - options = Locales.entries.map(::Option), - onOptionSelected = { locale.value = it.name }, - valueConverter = { value -> - when (value) { - Locales.RU -> Locales.RU.value - else -> Locales.EN.value - } - }, - modifier = Modifier.weight(0.5f) - ) - } + Text( + text = stringResource(Res.string.settings), + fontSize = 28.sp, + fontWeight = FontWeight.W500 + ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = stringResource(Res.string.font), - modifier = Modifier.weight(0.5f) - ) - Spacer(modifier = Modifier.width(16.dp)) - ComboBox( - value = fontEnum.value, - options = Fonts.entries.map(::Option), - onOptionSelected = { font.value = it.name }, - valueConverter = { value -> - when (value) { - Fonts.NOTO_SANS_ARABIC -> Fonts.NOTO_SANS_ARABIC.value - else -> Fonts.NOTO_SANS.value - } - }, - modifier = Modifier.weight(0.5f) - ) + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy( + space = 4.dp, + alignment = Alignment.Bottom + ), + modifier = Modifier.weight(1f) + ) { + CustomTextButton( + onClick = { UrlManager.replaceAll(HomeScreen(user)) }, + icon = Icons.Default.Home, + text = stringResource(Res.string.home) + ) + CustomTextButton( + onClick = { + accessToken = null + UrlManager.replaceAll(LoginScreen()) + }, + icon = Icons.Default.Person, + text = stringResource( + Res.string.sign_out, + user.username + ) + ) + } + } } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() + Box( + modifier = Modifier + .weight(0.7f) + .fillMaxHeight() + .padding(start = 48.dp), ) { - Text( - text = stringResource(Res.string.models), - modifier = Modifier.weight(0.5f) - ) - Spacer(modifier = Modifier.width(16.dp)) - MultiSelectList( - items = models, - selected = models.filter { it.active.value }, - valueConverter = { it.model }, - onSelect = { model -> - val activeModels = models.filter { it.active.value } - val status = !model.active.value + Column( + verticalArrangement = Arrangement.spacedBy(20.dp), + modifier = Modifier + .padding(top = 8.dp) + .width(800.dp) + .verticalScroll(rememberScrollState()) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(Res.string.color_scheme)) + ComboBox( + value = themeEnum.value, + options = Theme.entries.map(::Option), + onOptionSelected = { theme.value = it.name }, + valueConverter = { value -> + when (value) { + Theme.LIGHT -> lightThemeStr + Theme.DARK -> darkThemeStr + else -> systemThemeStr + } + }, + modifier = Modifier.width(300.dp) + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(Res.string.system_language)) + ComboBox( + value = localeEnum.value, + options = Locales.entries.map(::Option), + onOptionSelected = { locale.value = it.name }, + valueConverter = { value -> + when (value) { + Locales.RU -> Locales.RU.value + else -> Locales.EN.value + } + }, + modifier = Modifier.width(300.dp) + ) + } + + if (user.admin) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + .padding(end = 12.dp) + .clickable( + interactionSource = null, + indication = null, + onClick = { isModelsExpanded = !isModelsExpanded } + ) + ) { + Text(text = stringResource(Res.string.models)) + Icon( + imageVector = if (isModelsExpanded) { + Icons.Default.KeyboardArrowUp + } else Icons.Default.KeyboardArrowDown, + contentDescription = null + ) + } + + AnimatedVisibility(visible = isModelsExpanded) { + MultiSelectList( + items = models, + selected = models.filter { it.active.value }, + valueConverter = { it.model }, + onSelect = { model -> + val activeModels = models.filter { it.active.value } + val status = !model.active.value - if (activeModels.size == MODELS_SIZE && status) { - coroutineScope.launch { - alert = getString( - Res.string.select_models_limit, - MODELS_SIZE + if (activeModels.size == MODELS_SIZE && status) { + coroutineScope.launch { + alert = getString( + Res.string.select_models_limit, + MODELS_SIZE + ) + } + } else { + model.active.value = status + } + }, + modifier = Modifier.padding(start = 16.dp) ) } - } else { - model.active.value = status } - }, - modifier = Modifier.weight(0.5f) - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = stringResource(Res.string.use_apostrophe_regex), - modifier = Modifier.weight(0.5f) - ) - Spacer(modifier = Modifier.width(16.dp)) - Row(modifier = Modifier.weight(0.5f)) { - Checkbox( - checked = apostropheIsSeparator, - onCheckedChange = { apostropheIsSeparator = it }, - modifier = Modifier.offset(x = (-8).dp) - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource( + Res.string.use_apostrophe_regex + ) + ) + Row(modifier = Modifier) { + Checkbox( + checked = apostropheIsSeparator, + onCheckedChange = { apostropheIsSeparator = it } + ) + } + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/BatchInfo.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/BatchInfo.kt index ace9a75..0b41928 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/BatchInfo.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/BatchInfo.kt @@ -1,20 +1,11 @@ package org.bibletranslationtools.wat.ui.control import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.layout.requiredWidth -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Circle -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -23,8 +14,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import org.bibletranslationtools.wat.data.Consensus -import org.bibletranslationtools.wat.data.SingletonWord +import org.bibletranslationtools.wat.domain.BatchProgress import org.jetbrains.compose.resources.stringResource import wordanalysistool.composeapp.generated.resources.Res import wordanalysistool.composeapp.generated.resources.likely_correct @@ -32,111 +22,83 @@ import wordanalysistool.composeapp.generated.resources.likely_incorrect import wordanalysistool.composeapp.generated.resources.names import wordanalysistool.composeapp.generated.resources.review_needed import wordanalysistool.composeapp.generated.resources.total_singletons -import wordanalysistool.composeapp.generated.resources.word_analysis @Composable -fun BatchInfo(singletons: List) { - val progress = if (singletons.isNotEmpty()) { - singletons.filter { it.correct != null }.size / singletons.size.toFloat() - } else 0f +fun BatchInfo( + info: BatchProgress?, + totalSingletons: Int, + modifier: Modifier = Modifier +) { + val reviewedProgress = info?.let { + val completed = (info.correct + it.incorrect).toFloat() + if (completed > 0) info.reviewed / completed else 0f + } ?: 0f - Column { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier + ) { Row( - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() ) { + LinearProgressIndicator( + progress = { reviewedProgress }, + modifier = Modifier.weight(1f), + gapSize = 0.dp + ) Text( - text = stringResource(Res.string.word_analysis), - fontSize = 22.sp, - fontWeight = FontWeight.Bold + text = "${(reviewedProgress * 100).toInt()}%", ) - Spacer(modifier = Modifier.requiredWidth(8.dp)) - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.requiredSize(40.dp) - ) { - CircularProgressIndicator(progress = { progress }) - Text( - text = "${(progress * 100).toInt()}%", - fontSize = 10.sp - ) - } } - Spacer(modifier = Modifier.height(8.dp)) Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() ) { - Icon( - imageVector = Icons.Default.Circle, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(12.dp) - ) Text(stringResource(Res.string.likely_incorrect)) - Spacer(modifier = Modifier.weight(1f)) - Text(text = singletons.filter { - it.result?.consensus == Consensus.LIKELY_INCORRECT - }.size.toString()) + Text(text = info?.incorrect?.toString() ?: "0") } Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() ) { - Icon( - imageVector = Icons.Default.Circle, - contentDescription = null, - tint = MaterialTheme.colorScheme.secondary, - modifier = Modifier.size(12.dp) - ) Text(stringResource(Res.string.review_needed)) - Spacer(modifier = Modifier.weight(1f)) - Text(text = singletons.filter { - it.result?.consensus == Consensus.NEEDS_REVIEW - }.size.toString()) + Text(text = info?.reviewNeeded?.toString() ?: "0") } Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() ) { - Icon( - imageVector = Icons.Default.Circle, - contentDescription = null, - tint = MaterialTheme.colorScheme.tertiary, - modifier = Modifier.size(12.dp) - ) Text(stringResource(Res.string.likely_correct)) - Spacer(modifier = Modifier.weight(1f)) - Text(text = singletons.filter { - it.result?.consensus == Consensus.LIKELY_CORRECT - }.size.toString()) + Text(text = info?.correct?.toString() ?: "0") } Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() ) { - Icon( - imageVector = Icons.Default.Circle, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(12.dp) - ) Text(stringResource(Res.string.names)) - Spacer(modifier = Modifier.weight(1f)) - Text(text = singletons.filter { - it.result?.consensus == Consensus.NAME - }.size.toString()) + Text(text = info?.name?.toString() ?: "0") } - Spacer(modifier = Modifier.height(16.dp)) - Row { - Spacer(modifier = Modifier.width(20.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outline + ) + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { Text( text = stringResource(Res.string.total_singletons), fontSize = 16.sp ) - Spacer(modifier = Modifier.weight(1f)) - Text(text = "${singletons.count { it.result != null }}/${singletons.size}") + Text( + text = info?.let { "${it.completed}/${it.total}" } ?: "0/$totalSingletons", + fontWeight = FontWeight.Bold + ) } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/BatchProgress.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/BatchProgress.kt index 7d8bb2e..e2b4bdf 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/BatchProgress.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/BatchProgress.kt @@ -24,11 +24,10 @@ fun BatchProgress( ) { Row( horizontalArrangement = Arrangement.Center, - modifier = modifier.fillMaxWidth() + modifier = modifier ) { Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth(0.8f) + horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = stringResource(Res.string.loading_results), @@ -41,7 +40,8 @@ fun BatchProgress( progress = { progress }, modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.primary, - trackColor = MaterialTheme.colorScheme.onSurface + trackColor = MaterialTheme.colorScheme.onSurface, + gapSize = 0.dp ) } progress == 0f -> { diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/ComboBox.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/ComboBox.kt index 9ea2bf1..ec47ad6 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/ComboBox.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/ComboBox.kt @@ -1,16 +1,22 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text -import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -19,6 +25,7 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.Dp @@ -35,6 +42,19 @@ data class Option( val icon: OptionIcon? = null, ) +@ExperimentalMaterial3Api +@Composable +fun TrailingIcon( + expanded: Boolean, + modifier: Modifier = Modifier, +) { + Icon( + imageVector = Icons.Filled.KeyboardArrowDown, + contentDescription = null, + modifier.rotate(if (expanded) 180f else 0f) + ) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ComboBox( @@ -42,7 +62,6 @@ fun ComboBox( options: List> = emptyList(), onOptionSelected: (T) -> Unit = {}, valueConverter: (T) -> String = { it?.toString() ?: "" }, - label: String? = null, modifier: Modifier = Modifier ) { var expanded by remember { mutableStateOf(false) } @@ -57,7 +76,7 @@ fun ComboBox( }, modifier = modifier, ) { - TextField( + OutlinedTextField( enabled = isEnabled(), modifier = Modifier.fillMaxWidth() .menuAnchor( @@ -67,15 +86,19 @@ fun ComboBox( readOnly = true, value = valueConverter(value), onValueChange = {}, - label = label?.let {{ Text(text = it) }}, - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) - }, - colors = ExposedDropdownMenuDefaults.textFieldColors(), + shape = MaterialTheme.shapes.small, + trailingIcon = { TrailingIcon(expanded = expanded) }, + colors = OutlinedTextFieldDefaults.colors( + + ) ) + + Spacer(modifier = Modifier.height(8.dp)) + ExposedDropdownMenu( expanded = expanded, - onDismissRequest = { expanded = false } + onDismissRequest = { expanded = false }, + containerColor = MaterialTheme.colorScheme.surface ) { for (option in options) { DropdownMenuItem( diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/CustomTextButton.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/CustomTextButton.kt new file mode 100644 index 0000000..94e569c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/CustomTextButton.kt @@ -0,0 +1,63 @@ +package org.bibletranslationtools.wat.ui.control + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.unit.dp + +@Composable +fun CustomTextButton( + onClick: () -> Unit, + icon: Painter, + text: String, + modifier: Modifier = Modifier +) { + Surface( + onClick = onClick, + shape = MaterialTheme.shapes.medium, + color = Color.Transparent, + modifier = modifier.padding(0.dp) + .height(28.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 0.dp) + ) { + Icon( + painter = icon, + contentDescription = text, + modifier = Modifier.size(18.dp) + ) + Text(text = text) + } + } +} + +@Composable +fun CustomTextButton( + onClick: () -> Unit, + icon: ImageVector, + text: String, + modifier: Modifier = Modifier +) { + CustomTextButton( + onClick = onClick, + icon = rememberVectorPainter(icon), + text = text, + modifier = modifier + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/FlagButton.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/FlagButton.kt new file mode 100644 index 0000000..c85ac22 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/FlagButton.kt @@ -0,0 +1,65 @@ +package org.bibletranslationtools.wat.ui.control + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.painterResource +import wordanalysistool.composeapp.generated.resources.Res +import wordanalysistool.composeapp.generated.resources.flag +import wordanalysistool.composeapp.generated.resources.flag_filled + +@Composable +fun FlagButton( + flagged: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true +) { + val border = if (flagged) { + MaterialTheme.colorScheme.error + } else MaterialTheme.colorScheme.outline + + val background = if (flagged) { + MaterialTheme.colorScheme.error.copy(alpha = 0.1f) + } else MaterialTheme.colorScheme.surface + + val icon = if (flagged) { + Res.drawable.flag_filled + } else Res.drawable.flag + + val iconColor = if (flagged) { + MaterialTheme.colorScheme.error + } else MaterialTheme.colorScheme.onBackground + + Surface( + onClick = onClick, + enabled = enabled, + border = BorderStroke(1.dp, border), + color = background, + shape = MaterialTheme.shapes.large, + modifier = modifier + .padding(0.dp) + .width(54.dp) + .height(32.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.padding(4.dp) + ) { + Icon( + painter = painterResource(icon), + contentDescription = "flag", + tint = iconColor + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/MessageToast.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/MessageToast.kt new file mode 100644 index 0000000..8bee293 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/MessageToast.kt @@ -0,0 +1,107 @@ +package org.bibletranslationtools.wat.ui.control + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.bibletranslationtools.wat.data.ToastType +import org.jetbrains.compose.resources.stringResource + +@Composable +fun MessageToast( + type: ToastType, + message: String, + onDismiss: () -> Unit, + autoDismissTimeout: Long = 8000 +) { + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + scope.launch { + delay(autoDismissTimeout) + onDismiss() + } + } + + Card( + modifier = Modifier + .width(500.dp) + .padding(horizontal = 16.dp), + shape = MaterialTheme.shapes.medium, + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + colors = CardDefaults.cardColors(containerColor = type.backgroundColor()) + ) { + Row( + modifier = Modifier.fillMaxWidth() + .height(IntrinsicSize.Min), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .width(8.dp) + .fillMaxHeight() + .background( + color = type.mainColor(), + shape = RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp)) + ) + + Icon( + imageVector = type.icon, + contentDescription = "icon", + tint = type.mainColor(), + modifier = Modifier.padding(12.dp) + ) + + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = 12.dp) + ) { + Text( + text = stringResource(type.title), + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onBackground + ) + Text( + text = message, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onBackground + ) + } + + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "dismiss", + tint = MaterialTheme.colorScheme.onBackground + ) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/MultiSelectList.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/MultiSelectList.kt index 8cd2a28..67856df 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/MultiSelectList.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/MultiSelectList.kt @@ -1,18 +1,19 @@ package org.bibletranslationtools.wat.ui.control import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @Composable @@ -29,23 +30,28 @@ fun MultiSelectList( ) { items.forEach { model -> val background = if (model in selected) { + MaterialTheme.colorScheme.primaryContainer + } else MaterialTheme.colorScheme.surface + val border = if (model in selected) { MaterialTheme.colorScheme.primary - } else MaterialTheme.colorScheme.background - val foreground = if (model in selected) { - MaterialTheme.colorScheme.onPrimary - } else MaterialTheme.colorScheme.onBackground + } else Color.Transparent Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clip(RoundedCornerShape(8.dp)) + modifier = Modifier.clip(MaterialTheme.shapes.small) .clickable { onSelect(model) } .background(background) + .border( + width = 1.dp, + color = border, + shape = MaterialTheme.shapes.small + ) .padding(8.dp) ) { Text( text = valueConverter(model), - color = foreground + color = MaterialTheme.colorScheme.onSurface ) } } diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/PageButton.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/PageButton.kt new file mode 100644 index 0000000..16073b4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/PageButton.kt @@ -0,0 +1,65 @@ +package org.bibletranslationtools.wat.ui.control + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp + +@Composable +fun PageButton( + page: Int, + enabled: Boolean = true, + active: Boolean = false, + onClick: (Int) -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .sizeIn( + minWidth = 40.dp, + minHeight = 40.dp, + maxHeight = 40.dp + ) + .clip(MaterialTheme.shapes.small) + .then( + if (enabled && !active) Modifier.clickable { + onClick(page) + } else Modifier + ) + .graphicsLayer { + alpha = if (enabled) 1f else 0.5f + } + .background( + color = if (active) { + MaterialTheme.colorScheme.primary + } else MaterialTheme.colorScheme.surface, + shape = MaterialTheme.shapes.small + ) + .border( + width = 1.dp, + color = if (active) { + MaterialTheme.colorScheme.primary + } else MaterialTheme.colorScheme.outline, + shape = MaterialTheme.shapes.small + ) + .padding(horizontal = 4.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = page.toString(), + color = if (active) { + MaterialTheme.colorScheme.onPrimary + } else MaterialTheme.colorScheme.onSurface + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/PaginationControls.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/PaginationControls.kt new file mode 100644 index 0000000..8f72203 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/PaginationControls.kt @@ -0,0 +1,97 @@ +package org.bibletranslationtools.wat.ui.control + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronLeft +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import wordanalysistool.composeapp.generated.resources.Res +import wordanalysistool.composeapp.generated.resources.back +import wordanalysistool.composeapp.generated.resources.page_info +import wordanalysistool.composeapp.generated.resources.save +import wordanalysistool.composeapp.generated.resources.save_next + +enum class SaveDirection { + NEXT, + PREV +} + +@Composable +fun PaginationControls( + currentPage: Int, + totalPages: Int, + enabled: Boolean = true, + onSave: (SaveDirection) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton( + onClick = { onSave(SaveDirection.PREV) }, + enabled = enabled && currentPage > 1, + shape = MaterialTheme.shapes.small, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outline + ), + contentPadding = PaddingValues(end = 8.dp) + ) { + Icon( + imageVector = Icons.Default.ChevronLeft, + contentDescription = "back" + ) + Text(stringResource(Res.string.back)) + } + + if (totalPages > 0) { + Text( + text = stringResource( + Res.string.page_info, + currentPage, + totalPages + ), + style = MaterialTheme.typography.bodyMedium + ) + } + + OutlinedButton( + onClick = { onSave(SaveDirection.NEXT) }, + shape = MaterialTheme.shapes.small, + enabled = enabled, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + contentPadding = PaddingValues(start = 8.dp) + ) { + Text( + text = if (currentPage < totalPages) { + stringResource(Res.string.save_next) + } else stringResource(Res.string.save) + ) + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = "save & next" + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/SearchableComboBox.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/SearchableComboBox.kt index 7a2eb45..f9bcee1 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/SearchableComboBox.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/SearchableComboBox.kt @@ -5,6 +5,7 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -17,6 +18,7 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.TextFieldValue +import org.bibletranslationtools.wat.ui.theme.getFontFamilyForText @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -57,6 +59,9 @@ fun SearchableComboBox( value = searchTextState, enabled = isEnabled(), label = label?.let {{ Text(text = it) }}, + textStyle = LocalTextStyle.current.copy( + fontFamily = getFontFamilyForText(searchTextState.text) + ), onValueChange = { searchTextState = it expanded = true @@ -78,7 +83,12 @@ fun SearchableComboBox( filteredOptions .forEach { option -> DropdownMenuItem( - text = { Text(valueConverter(option)) }, + text = { + Text( + text = valueConverter(option), + fontFamily = getFontFamilyForText(valueConverter(option)) + ) + }, onClick = { searchTextState = TextFieldValue(valueConverter(option)) expanded = false diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/SingletonCard.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/SingletonCard.kt deleted file mode 100644 index 72b0560..0000000 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/SingletonCard.kt +++ /dev/null @@ -1,215 +0,0 @@ -package org.bibletranslationtools.wat.ui.control - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Undo -import androidx.compose.material.icons.filled.Circle -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDirection -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import org.bibletranslationtools.wat.data.Consensus -import org.bibletranslationtools.wat.data.SingletonWord -import org.jetbrains.compose.resources.stringResource -import wordanalysistool.composeapp.generated.resources.Res -import wordanalysistool.composeapp.generated.resources.correct -import wordanalysistool.composeapp.generated.resources.incorrect -import wordanalysistool.composeapp.generated.resources.is_word_correct -import wordanalysistool.composeapp.generated.resources.is_word_name -import wordanalysistool.composeapp.generated.resources.likely_correct -import wordanalysistool.composeapp.generated.resources.likely_incorrect -import wordanalysistool.composeapp.generated.resources.names -import wordanalysistool.composeapp.generated.resources.no -import wordanalysistool.composeapp.generated.resources.review_needed -import wordanalysistool.composeapp.generated.resources.scripture_reference -import wordanalysistool.composeapp.generated.resources.undo -import wordanalysistool.composeapp.generated.resources.yes - -@Composable -fun SingletonCard( - word: SingletonWord, - onAnswer: (Boolean?) -> Unit, - modifier: Modifier = Modifier -) { - val question = if (word.result?.consensus == Consensus.NAME) { - stringResource(Res.string.is_word_name) - } else { - stringResource(Res.string.is_word_correct) - } - val answerCorrect = stringResource(Res.string.yes) - val answerIncorrect = stringResource(Res.string.no) - - val localizedConsensus = Consensus.entries.associateWith { localizeConsensus(it) } - - Surface( - shape = RoundedCornerShape(8.dp), - modifier = modifier.shadow(4.dp, RoundedCornerShape(8.dp)) - ) { - Column( - verticalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.padding(16.dp) - ) { - Column { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Circle, - tint = consensusBgColor(word.result?.consensus), - contentDescription = null, - modifier = Modifier.size(12.dp) - ) - Text( - text = word.result?.consensus?.let { localizedConsensus[it] } ?: "", - fontSize = 12.sp - ) - } - Spacer(modifier = Modifier.height(24.dp)) - Text(text = question) - SelectionContainer { - Text( - text = word.word, - fontSize = 20.sp, - fontWeight = FontWeight.Bold, - color = consensusFgColor(word.result?.consensus) - ) - } - Spacer(modifier = Modifier.height(24.dp)) - Text(text = stringResource(Res.string.scripture_reference)) - Spacer(modifier = Modifier.height(24.dp)) - SelectionContainer { - val reference = StringBuilder() - reference.append("${word.ref.bookName} ") - reference.append("(${word.ref.bookSlug.uppercase()}) ") - reference.append("${word.ref.chapter}:${word.ref.number} ") - Text( - text = reference.toString(), - style = LocalTextStyle.current.copy( - textDirection = TextDirection.ContentOrLtr - ), - modifier = Modifier.fillMaxWidth() - ) - } - Spacer(modifier = Modifier.height(12.dp)) - SelectionContainer { - val annotatedText = buildAnnotatedString { - val text = word.ref.text - val index = text.indexOf(word.word) - append(text.substring(0, index)) - withStyle( - style = SpanStyle( - color = consensusFgColor(word.result?.consensus) - ) - ) { - append(word.word) - } - append(text.substring(index + word.word.length, text.length)) - } - Text( - text = annotatedText, - style = LocalTextStyle.current.copy( - textDirection = TextDirection.ContentOrLtr - ), - modifier = Modifier.fillMaxWidth() - ) - } - Spacer(modifier = Modifier.height(24.dp)) - Row( - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - if (word.result != null && word.correct == null) { - Button( - onClick = { onAnswer(false) }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error - ) - ) { - Text(text = answerIncorrect) - } - Spacer(modifier = Modifier.width(16.dp)) - Button( - onClick = { onAnswer(true) }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.tertiary - ) - ) { - Text(text = answerCorrect) - } - } else if (word.correct != null) { - Button( - onClick = { onAnswer(null) }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Undo, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text(text = stringResource(Res.string.undo)) - } - } - } - } - } - } -} - -@Composable -private fun consensusBgColor(consensus: Consensus?): Color { - return when (consensus) { - Consensus.LIKELY_CORRECT -> MaterialTheme.colorScheme.tertiary - Consensus.LIKELY_INCORRECT -> MaterialTheme.colorScheme.error - Consensus.NAME -> MaterialTheme.colorScheme.primary - Consensus.NEEDS_REVIEW -> MaterialTheme.colorScheme.secondary - else -> MaterialTheme.colorScheme.background - } -} - -@Composable -private fun consensusFgColor(consensus: Consensus?): Color { - return when (consensus) { - null -> MaterialTheme.colorScheme.onBackground - else -> consensusBgColor(consensus) - } -} - -@Composable -private fun localizeConsensus(consensus: Consensus): String { - return when (consensus) { - Consensus.LIKELY_CORRECT -> stringResource(Res.string.likely_correct) - Consensus.LIKELY_INCORRECT -> stringResource(Res.string.likely_incorrect) - Consensus.NAME -> stringResource(Res.string.names) - Consensus.NEEDS_REVIEW -> stringResource(Res.string.review_needed) - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/SingletonRow.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/SingletonRow.kt index 85de876..ce0d223 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/SingletonRow.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/SingletonRow.kt @@ -1,84 +1,284 @@ package org.bibletranslationtools.wat.ui.control +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp -import org.bibletranslationtools.wat.data.Consensus -import org.bibletranslationtools.wat.data.Direction -import org.bibletranslationtools.wat.data.SingletonWord +import androidx.compose.ui.unit.sp +import org.bibletranslationtools.wat.data.ReviewWord +import org.bibletranslationtools.wat.ui.theme.getFontFamilyForText +import org.jetbrains.compose.resources.stringResource +import wordanalysistool.composeapp.generated.resources.Res +import wordanalysistool.composeapp.generated.resources.view_less +import wordanalysistool.composeapp.generated.resources.view_more +import kotlin.math.max +import kotlin.math.min @Composable fun SingletonRow( - singleton: SingletonWord, - selected: Boolean, - direction: Direction, - onSelect: () -> Unit + singleton: ReviewWord, + onFlagged: () -> Unit, + enabled: Boolean = true, ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .clickable { onSelect() } - .padding(horizontal = 8.dp) - ) { - if (direction == Direction.LTR) { - renderText(singleton, selected) - renderIcon(singleton.correct) - } else { - renderIcon(singleton.correct) - renderText(singleton, selected) + val density = LocalDensity.current + val reference = "${singleton.ref.book.uppercase()} " + + "${singleton.ref.chapter}:${singleton.ref.verse}" + val style = TextStyle.Default.copy( + lineHeight = 28.sp, + fontSize = 19.sp + ) + val refHorizontalPadding = 8.dp + val refVerticalPadding = 2.dp + + val textMeasurer = rememberTextMeasurer() + val placeholderWidth = remember(reference, refHorizontalPadding, textMeasurer, style) { + val textWidthInPixels = textMeasurer.measure(reference, style).size.width + val paddingInPixels = with(density) { (refHorizontalPadding * 2).toPx() } + val totalWidthInPixels = textWidthInPixels + paddingInPixels + + with(density) { + totalWidthInPixels.toSp() } } -} -@Composable -private fun renderIcon(correct: Boolean?) { - correct?.let { - Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - } ?: Spacer(Modifier.size(16.dp)) + val flagged = singleton.correct == false + var isExpanded by remember { mutableStateOf(false) } + + val errorColor = MaterialTheme.colorScheme.error + val normalColor = MaterialTheme.colorScheme.onBackground + + val referenceTag = "reference" + val fullAnnotatedText = remember(singleton, flagged) { + buildAnnotatedString { + appendInlineContent(referenceTag, reference) + + val textToSearch = singleton.ref.text + val wordToFind = singleton.word + + val regex = Regex( + pattern = "(? MaterialTheme.colorScheme.error - Consensus.LIKELY_CORRECT -> MaterialTheme.colorScheme.tertiary - Consensus.NAME -> MaterialTheme.colorScheme.primary - Consensus.NEEDS_REVIEW -> MaterialTheme.colorScheme.secondary - else -> MaterialTheme.colorScheme.onBackground - }, - style = LocalTextStyle.current.copy( - textDirection = TextDirection.ContentOrLtr - ) +private fun createSnippetString( + fullText: String, + wordToFind: String, + contextLength: Int = 40 +): String { + val regex = Regex( + pattern = "(? 0) append("... ") + append(snippetText) + if (snippetEnd < fullText.length) append(" ...") + } +} diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/StatusBar.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/StatusBar.kt new file mode 100644 index 0000000..24a1378 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/StatusBar.kt @@ -0,0 +1,60 @@ +package org.bibletranslationtools.wat.ui.control + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.bibletranslationtools.wat.domain.BatchError + +data class Status( + val info: Any, + val time: String +) + +@Composable +fun StatusBar( + status: Status?, + onToggleStatusBox: () -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + HorizontalDivider(color = MaterialTheme.colorScheme.outline) + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + .height(36.dp) + .padding(start = 16.dp) + ) { + val message = status?.let { + when (it.info) { + is String -> it.info + is BatchError -> it.info.message + else -> null + } + } ?: "" + Text( + text = message, + fontSize = 12.sp + ) + IconButton(onClick = onToggleStatusBox) { + Icon(imageVector = Icons.Default.Info, contentDescription = null) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/StatusBox.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/StatusBox.kt index 9d2f72b..cb620f6 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/StatusBox.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/StatusBox.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Info import androidx.compose.material3.Icon @@ -24,8 +23,6 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.bibletranslationtools.wat.domain.BatchError -import org.bibletranslationtools.wat.ui.Status - @Composable fun StatusBox( @@ -34,12 +31,12 @@ fun StatusBox( modifier: Modifier = Modifier ) { Surface( - shape = RoundedCornerShape(8.dp), + shape = MaterialTheme.shapes.medium, modifier = modifier .fillMaxWidth(0.35f) .fillMaxHeight(0.6f) - .offset(y = (-46).dp, x = (-10).dp) - .shadow(4.dp, RoundedCornerShape(8.dp)) + .offset(y = (-48).dp, x = (-10).dp) + .shadow(4.dp, MaterialTheme.shapes.medium) ) { LazyColumn( modifier = Modifier.padding(8.dp) diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/TopNavigationBar.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/TopNavigationBar.kt index 4d472b2..a1219a0 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/TopNavigationBar.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/control/TopNavigationBar.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.DropdownMenu @@ -16,6 +15,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -42,18 +42,11 @@ data class ExtraAction( val onClick: () -> Unit ) -enum class PageType { - HOME, - SETTINGS, - ANALYZE -} - @OptIn(ExperimentalMaterial3Api::class) @Composable fun TopNavigationBar( title: String, user: User, - page: PageType, vararg extraAction: ExtraAction, ) { val navigator = LocalNavigator.currentOrThrow @@ -68,16 +61,6 @@ fun TopNavigationBar( title = { SingleLineText(title) }, - navigationIcon = { - if (page != PageType.HOME) { - IconButton(onClick = { navigator.pop() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null - ) - } - } - }, actions = { IconButton(onClick = { showDropDownMenu = true }) { Icon( @@ -88,6 +71,7 @@ fun TopNavigationBar( DropdownMenu( expanded = showDropDownMenu, onDismissRequest = { showDropDownMenu = false }, + containerColor = MaterialTheme.colorScheme.surface, modifier = Modifier.width(200.dp) ) { Row( @@ -100,23 +84,21 @@ fun TopNavigationBar( HorizontalDivider() - if (page != PageType.SETTINGS) { - DropdownMenuItem( - text = { Text(stringResource(Res.string.settings)) }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Settings, - contentDescription = null - ) - }, - onClick = { - showDropDownMenu = false - if (navigator.lastItem !is SettingsScreen) { - navigator.push(SettingsScreen(user)) - } + DropdownMenuItem( + text = { Text(stringResource(Res.string.settings)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null + ) + }, + onClick = { + showDropDownMenu = false + if (navigator.lastItem !is SettingsScreen) { + navigator.push(SettingsScreen(user)) } - ) - } + } + ) actionsState.forEach { DropdownMenuItem( text = { Text(it.title) }, diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/dialogs/AlertDialog.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/dialogs/AlertDialog.kt index 0c2951d..96be1dd 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/dialogs/AlertDialog.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/dialogs/AlertDialog.kt @@ -11,9 +11,9 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -35,7 +35,7 @@ fun AlertDialog( Surface( modifier = Modifier.fillMaxWidth() .height(IntrinsicSize.Min) - .clip(RoundedCornerShape(16.dp)) + .clip(MaterialTheme.shapes.large) ) { Column( verticalArrangement = Arrangement.SpaceAround, diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/dialogs/BatchErrorDialog.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/dialogs/BatchErrorDialog.kt index a13061b..2ef7686 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/dialogs/BatchErrorDialog.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/dialogs/BatchErrorDialog.kt @@ -12,9 +12,9 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -41,7 +41,7 @@ fun BatchErrorDialog( Surface( modifier = Modifier.fillMaxWidth() .height(IntrinsicSize.Min) - .clip(RoundedCornerShape(16.dp)) + .clip(MaterialTheme.shapes.large) ) { Column( verticalArrangement = Arrangement.SpaceAround, diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/dialogs/LanguagesDialog.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/dialogs/LanguagesDialog.kt index b56e593..79f5af2 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/dialogs/LanguagesDialog.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/dialogs/LanguagesDialog.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -70,7 +69,7 @@ fun LanguagesDialog( Surface( modifier = Modifier .fillMaxWidth(0.7f), - shape = RoundedCornerShape(16.dp), + shape = MaterialTheme.shapes.large, ) { Column( verticalArrangement = Arrangement.spacedBy(16.dp), diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/dialogs/ProgressDialog.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/dialogs/ProgressDialog.kt index e75db3f..6dfa6b7 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/dialogs/ProgressDialog.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/dialogs/ProgressDialog.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -24,7 +23,7 @@ fun ProgressDialog(progress: Progress) { Surface( modifier = Modifier.fillMaxWidth() .height(200.dp), - shape = RoundedCornerShape(16.dp) + shape = MaterialTheme.shapes.large ) { Column( verticalArrangement = Arrangement.SpaceAround, diff --git a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/theme/Theme.kt b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/theme/Theme.kt index 981268b..c1015b9 100644 --- a/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/theme/Theme.kt +++ b/composeApp/src/commonMain/kotlin/org/bibletranslationtools/wat/ui/theme/Theme.kt @@ -3,7 +3,6 @@ package org.bibletranslationtools.wat.ui.theme import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable @@ -12,13 +11,27 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import org.jetbrains.compose.resources.Font import wordanalysistool.composeapp.generated.resources.Res -import wordanalysistool.composeapp.generated.resources.noto_sans -import wordanalysistool.composeapp.generated.resources.noto_sans_arabic +import wordanalysistool.composeapp.generated.resources.noto_sans_arabic_bold +import wordanalysistool.composeapp.generated.resources.noto_sans_arabic_regular +import wordanalysistool.composeapp.generated.resources.noto_sans_bold +import wordanalysistool.composeapp.generated.resources.noto_sans_chinese_simplified_bold +import wordanalysistool.composeapp.generated.resources.noto_sans_chinese_simplified_regular +import wordanalysistool.composeapp.generated.resources.noto_sans_korean_bold +import wordanalysistool.composeapp.generated.resources.noto_sans_korean_regular +import wordanalysistool.composeapp.generated.resources.noto_sans_malayalam_bold +import wordanalysistool.composeapp.generated.resources.noto_sans_malayalam_regular +import wordanalysistool.composeapp.generated.resources.noto_sans_regular +import wordanalysistool.composeapp.generated.resources.noto_serif_tibetan_bold +import wordanalysistool.composeapp.generated.resources.noto_serif_tibetan_regular val LightColorScheme = lightColorScheme( - primary = Color(0xFF0056D1), + primary = Color(0xFF478CFF), + primaryContainer = Color(0xFFEEF0FF), secondary = Color(0xFFE99A2E), + secondaryContainer = Color(0xFFFFEEDF), tertiary = Color(0xFF63C76C), + tertiaryContainer = Color(0xFFE2F7E7), + onTertiaryContainer = Color(0xFF999999), background = Color(0xFFF2F2F2), surface = Color(0xFFFFFFFF), error = Color(0xFFC3362D), @@ -26,13 +39,20 @@ val LightColorScheme = lightColorScheme( onSecondary = Color.White, onTertiary = Color.White, onBackground = Color(0xFF444444), - onSurface = Color(0xFF444444) + onSurface = Color(0xFF0F2F4C), + onSurfaceVariant = Color(0xFF516B86), + scrim = Color(0xFF444444), + outline = Color(0xFFE6E6E6) ) val DarkColorScheme = darkColorScheme( primary = Color(0xFF4B8EFF), - secondary = Color(0xFFFFB655), + primaryContainer = Color(0xFFEEF0FF), + secondary = Color(0xff9c6f33), + secondaryContainer = Color(0xff534a40), tertiary = Color(0xFF7EE588), + tertiaryContainer = Color(0xFFE2F7E7), + onTertiaryContainer = Color(0xFF999999), background = Color(0xFF141516), surface = Color(0xFF0F1011), error = Color(0xFFFF6B62), @@ -40,47 +60,69 @@ val DarkColorScheme = darkColorScheme( onSecondary = Color.White, onTertiary = Color.White, onBackground = Color(0xFFC9C9C9), - onSurface = Color(0xFFC9C9C9) + onSurface = Color(0xFFC9C9C9), + onSurfaceVariant = Color(0xFF516B86), + scrim = Color(0xFF444444), + outline = Color(0xFFE6E6E6) ) -val ColorScheme.semiTransparent: Color - @Composable get() = Color(0x88000000) +@Composable +fun defaultFontFamily() = FontFamily( + Font(Res.font.noto_sans_regular, FontWeight.Normal), + Font(Res.font.noto_sans_bold, FontWeight.Bold) +) @Composable -fun NotoSansFontFamily() = FontFamily( - Font(Res.font.noto_sans, FontWeight.Normal), +fun arabicFontFamily() = FontFamily( + Font(Res.font.noto_sans_arabic_regular, FontWeight.Normal), + Font(Res.font.noto_sans_arabic_bold, FontWeight.Bold) ) @Composable -fun NotoSansArabicFontFamily() = FontFamily( - Font(Res.font.noto_sans_arabic, FontWeight.Normal), +fun chineseSimplifiedFontFamily() = FontFamily( + Font(Res.font.noto_sans_chinese_simplified_regular, FontWeight.Normal), + Font(Res.font.noto_sans_chinese_simplified_bold, FontWeight.Bold) ) @Composable -fun NotoSansTypography(fontFamily: FontFamily) = Typography().run { - copy( - displayLarge = displayLarge.copy(fontFamily = fontFamily), - displayMedium = displayMedium.copy(fontFamily = fontFamily), - displaySmall = displaySmall.copy(fontFamily = fontFamily), - headlineLarge = headlineLarge.copy(fontFamily = fontFamily), - headlineMedium = headlineMedium.copy(fontFamily = fontFamily), - headlineSmall = headlineSmall.copy(fontFamily = fontFamily), - titleLarge = titleLarge.copy(fontFamily = fontFamily), - titleMedium = titleMedium.copy(fontFamily = fontFamily), - titleSmall = titleSmall.copy(fontFamily = fontFamily), - bodyLarge = bodyLarge.copy(fontFamily = fontFamily), - bodyMedium = bodyMedium.copy(fontFamily = fontFamily), - bodySmall = bodySmall.copy(fontFamily = fontFamily), - labelLarge = labelLarge.copy(fontFamily = fontFamily), - labelMedium = labelMedium.copy(fontFamily = fontFamily), - labelSmall = labelSmall.copy(fontFamily = fontFamily) - ) +fun tibetanFontFamily() = FontFamily( + Font(Res.font.noto_serif_tibetan_regular, FontWeight.Normal), + Font(Res.font.noto_serif_tibetan_bold, FontWeight.Bold) +) + +@Composable +fun koreanFontFamily() = FontFamily( + Font(Res.font.noto_sans_korean_regular, FontWeight.Normal), + Font(Res.font.noto_sans_korean_bold, FontWeight.Bold) +) + +@Composable +fun malayalamFontFamily() = FontFamily( + Font(Res.font.noto_sans_malayalam_regular, FontWeight.Normal), + Font(Res.font.noto_sans_malayalam_bold, FontWeight.Bold) +) + +@Composable +fun getFontFamilyForText(text: String): FontFamily { + val arabicRegex = Regex(".*[\\u0600-\\u06FF].*") + val chineseSimplifiedRegex = Regex(".*[\\u4E00-\\u9FFF\\u3400-\\u4DBF\\uFF00-\\uFFEF].*") + val tibetanRegex = Regex(".*[\\u0F00-\\u0FFF].*") + val koreanRegex = Regex(".*[\\uAC00-\\uD7A3\\u1100-\\u11FF\\u3130-\\u318F\\uA960-\\uA97F\\uD7B0-\\uD7FF].*") + val malayalamRegex = Regex(".*[\\u0D00-\\u0D7F].*") + + return when { + arabicRegex.matches(text) -> arabicFontFamily() + chineseSimplifiedRegex.matches(text) -> chineseSimplifiedFontFamily() + tibetanRegex.matches(text) -> tibetanFontFamily() + koreanRegex.matches(text) -> koreanFontFamily() + malayalamRegex.matches(text) -> malayalamFontFamily() + else -> defaultFontFamily() + } } @Composable fun MainAppTheme( themeColorScheme: ColorScheme? = null, - fontFamily: FontFamily = NotoSansFontFamily(), content: @Composable () -> Unit ) { val colorScheme = when { @@ -91,7 +133,6 @@ fun MainAppTheme( MaterialTheme( colorScheme = colorScheme, - content = content, - typography = NotoSansTypography(fontFamily) + content = content ) } diff --git a/composeApp/src/desktopMain/kotlin/org/bibletranslationtools/wat/platform/FileCache.desktop.kt b/composeApp/src/desktopMain/kotlin/org/bibletranslationtools/wat/platform/FileCache.desktop.kt new file mode 100644 index 0000000..59484ef --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/org/bibletranslationtools/wat/platform/FileCache.desktop.kt @@ -0,0 +1,43 @@ +package org.bibletranslationtools.wat.platform + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.security.MessageDigest + +actual class FileCache { + private val cacheDir: Path = Paths.get(appDirPath, ".cache") + + init { + Files.createDirectories(cacheDir) + } + + private fun urlToFileName(url: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val hashBytes = digest.digest(url.toByteArray(Charsets.UTF_8)) + return hashBytes.joinToString("") { "%02x".format(it) } + } + + actual suspend fun get(url: String): ByteArray? = withContext(Dispatchers.IO) { + val fileName = urlToFileName(url) + val filePath = cacheDir.resolve(fileName) + + if (Files.exists(filePath)) { + Files.readAllBytes(filePath) + } else { + null + } + } + + actual suspend fun put(url: String, data: ByteArray) { + withContext(Dispatchers.IO) { + val fileName = urlToFileName(url) + val filePath = cacheDir.resolve(fileName) + Files.write(filePath, data) + } + } +} + +actual fun createFileCache(): FileCache = FileCache() \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/org/bibletranslationtools/wat/platform/Platform.desktop.kt b/composeApp/src/desktopMain/kotlin/org/bibletranslationtools/wat/platform/Platform.desktop.kt index 3cd57dd..d118e66 100644 --- a/composeApp/src/desktopMain/kotlin/org/bibletranslationtools/wat/platform/Platform.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/org/bibletranslationtools/wat/platform/Platform.desktop.kt @@ -1,7 +1,5 @@ package org.bibletranslationtools.wat.platform -import io.github.mxaln.kotlin.document.store.core.DataStore -import io.github.mxaln.kotlin.document.store.stores.leveldb.LevelDBStore import io.ktor.client.engine.HttpClientEngine import io.ktor.client.engine.cio.CIO import java.io.File @@ -18,8 +16,6 @@ actual val appDirPath: String return appDir.canonicalPath } -actual val dbStore: DataStore = LevelDBStore.open(appDirPath) - actual val httpClientEngine: HttpClientEngine get() { return CIO.create() diff --git a/composeApp/src/desktopMain/kotlin/org/bibletranslationtools/wat/preview/control/ComboBox.kt b/composeApp/src/desktopMain/kotlin/org/bibletranslationtools/wat/preview/control/ComboBox.kt index faceebc..613ed78 100644 --- a/composeApp/src/desktopMain/kotlin/org/bibletranslationtools/wat/preview/control/ComboBox.kt +++ b/composeApp/src/desktopMain/kotlin/org/bibletranslationtools/wat/preview/control/ComboBox.kt @@ -14,8 +14,7 @@ fun ComboBoxPreview() { value = "Option 2", options = listOf("Option 1", "Option 2", "Option 3").map(::Option), onOptionSelected = {}, - valueConverter = { it }, - label = "Select an option" + valueConverter = { it } ) } } \ No newline at end of file diff --git a/composeApp/src/javaMain/kotlin/org/bibletranslationtools/wat/navigation/PlatformUrlHooks.java.kt b/composeApp/src/javaMain/kotlin/org/bibletranslationtools/wat/navigation/PlatformUrlHooks.java.kt new file mode 100644 index 0000000..103af85 --- /dev/null +++ b/composeApp/src/javaMain/kotlin/org/bibletranslationtools/wat/navigation/PlatformUrlHooks.java.kt @@ -0,0 +1,17 @@ +package org.bibletranslationtools.wat.navigation + +import androidx.compose.runtime.Composable +import cafe.adriel.voyager.navigator.Navigator + +@Composable +actual fun BindNavigatorToPlatform(navigator: Navigator) { + // No-op: There are no browser back/forward buttons on the JVM. +} + +internal actual fun performPushState(path: String) { /* No-op */ } + +internal actual fun performReplaceState(path: String) { /* No-op */ } + +internal actual fun performBackNavigation() { /* No-op */ } + +internal actual fun performReplaceAll(paths: List) { /* No-op */ } \ No newline at end of file diff --git a/composeApp/src/javaMain/kotlin/org/bibletranslationtools/wat/platform/Platform.java.kt b/composeApp/src/javaMain/kotlin/org/bibletranslationtools/wat/platform/Platform.java.kt new file mode 100644 index 0000000..0fca879 --- /dev/null +++ b/composeApp/src/javaMain/kotlin/org/bibletranslationtools/wat/platform/Platform.java.kt @@ -0,0 +1,12 @@ +package org.bibletranslationtools.wat.platform + +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.dialogs.openFileSaver +import io.github.vinceglb.filekit.write + +actual suspend fun saveFile(bytes: ByteArray, filename: String, extension: String) { + FileKit.openFileSaver( + suggestedName = filename, + extension = extension + )?.write(bytes) +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/org/bibletranslationtools/wat/main.kt b/composeApp/src/wasmJsMain/kotlin/org/bibletranslationtools/wat/main.kt index 3ef0d87..bdd78af 100644 --- a/composeApp/src/wasmJsMain/kotlin/org/bibletranslationtools/wat/main.kt +++ b/composeApp/src/wasmJsMain/kotlin/org/bibletranslationtools/wat/main.kt @@ -3,14 +3,18 @@ package org.bibletranslationtools.wat import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.window.ComposeViewport import kotlinx.browser.document +import kotlinx.browser.window import org.bibletranslationtools.wat.di.initKoin @OptIn(ExperimentalComposeUiApi::class) fun main() { + val path = window.location.pathname + initKoin() + document.body?.let { ComposeViewport(it) { - App() + App(path) } } } \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/org/bibletranslationtools/wat/navigation/PlatformUrlHooks.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/org/bibletranslationtools/wat/navigation/PlatformUrlHooks.wasmJs.kt new file mode 100644 index 0000000..6d638e3 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/org/bibletranslationtools/wat/navigation/PlatformUrlHooks.wasmJs.kt @@ -0,0 +1,38 @@ +package org.bibletranslationtools.wat.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import cafe.adriel.voyager.navigator.Navigator +import kotlinx.browser.window +import org.bibletranslationtools.wat.ui.LoginScreen + +@Composable +actual fun BindNavigatorToPlatform(navigator: Navigator) { + LaunchedEffect(navigator) { + window.onpopstate = { + val newPath = window.location.pathname + navigator.replaceAll(LoginScreen(newPath)) + } + } +} + +internal actual fun performPushState(path: String) { + window.history.pushState(null, "", path) +} + +internal actual fun performReplaceState(path: String) { + window.history.replaceState(null, "", path) +} + +internal actual fun performBackNavigation() { + window.history.back() +} + +internal actual fun performReplaceAll(paths: List) { + if (paths.isNotEmpty()) { + window.history.replaceState(null, "", paths.first()) + paths.drop(1).forEach { p -> + window.history.pushState(null, "", p) + } + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/org/bibletranslationtools/wat/platform/FileCache.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/org/bibletranslationtools/wat/platform/FileCache.wasmJs.kt new file mode 100644 index 0000000..05c96ac --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/org/bibletranslationtools/wat/platform/FileCache.wasmJs.kt @@ -0,0 +1,46 @@ +package org.bibletranslationtools.wat.platform + +import io.ktor.util.toJsArray +import kotlinx.browser.window +import kotlinx.coroutines.await +import org.khronos.webgl.ArrayBuffer +import org.khronos.webgl.Int8Array +import org.w3c.fetch.Response +import org.w3c.workers.Cache +import kotlin.ByteArray + +@OptIn(ExperimentalWasmJsInterop::class) +actual class FileCache(private val cacheName: String = "wat-cache") { + + private suspend fun openCache(): Cache = + window.caches.open(cacheName).await() + + actual suspend fun get(url: String): ByteArray? { + val cache = openCache() + val matched = cache.match(url).await() + + return matched?.let { + val response = it.unsafeCast() + val arrayBuffer = response.arrayBuffer().await() + + val jsInt8Array = Int8Array(arrayBuffer) + ByteArray(jsInt8Array.length) { index -> + getByteFromArrayBuffer(arrayBuffer, index) + } + } + } + + actual suspend fun put(url: String, data: ByteArray) { + val cache = openCache() + val jsUint8Array = data.toJsArray() + val response = Response(jsUint8Array) + cache.put(url, response).await() + } +} + +actual fun createFileCache(): FileCache { + return FileCache() +} + +@JsFun("(buffer, index) => new Int8Array(buffer)[index]") +private external fun getByteFromArrayBuffer(buffer: ArrayBuffer, index: Int): Byte \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/org/bibletranslationtools/wat/platform/Platform.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/org/bibletranslationtools/wat/platform/Platform.wasmJs.kt index cbab66a..de92fd5 100644 --- a/composeApp/src/wasmJsMain/kotlin/org/bibletranslationtools/wat/platform/Platform.wasmJs.kt +++ b/composeApp/src/wasmJsMain/kotlin/org/bibletranslationtools/wat/platform/Platform.wasmJs.kt @@ -1,19 +1,21 @@ package org.bibletranslationtools.wat.platform -import io.github.mxaln.kotlin.document.store.core.DataStore -import io.github.mxaln.kotlin.document.store.stores.browser.BrowserStore +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.download import io.ktor.client.engine.HttpClientEngine import io.ktor.client.engine.js.Js actual val appDirPath: String get() = "/" -actual val dbStore: DataStore = BrowserStore - - actual val httpClientEngine: HttpClientEngine +actual val httpClientEngine: HttpClientEngine get() { return Js.create() } actual fun applyLocale(iso: String) { - println("Applying locale not implemented in wasmJs yet") + println("Applying locale not implemented in web yet") +} + +actual suspend fun saveFile(bytes: ByteArray, filename: String, extension: String) { + FileKit.download(bytes, "$filename.$extension") } \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/resources/index.html b/composeApp/src/wasmJsMain/resources/index.html index a0d7d32..c4b7e50 100644 --- a/composeApp/src/wasmJsMain/resources/index.html +++ b/composeApp/src/wasmJsMain/resources/index.html @@ -4,6 +4,21 @@ WordAnalysisTool + + + diff --git a/gradle.properties b/gradle.properties index 30346c5..867de72 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,4 +12,14 @@ android.nonTransitiveRClass=true android.useAndroidX=true #Compose -org.jetbrains.compose.experimental.jscanvas.enabled=true \ No newline at end of file +org.jetbrains.compose.experimental.jscanvas.enabled=true +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.builtInKotlin=false +android.newDsl=false \ No newline at end of file diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..baa28d1 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 54ea719..0faea6d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,46 +1,33 @@ [versions] -agp = "8.9.0" -android-compileSdk = "35" +agp = "9.2.1" +android-compileSdk = "37" android-minSdk = "26" -android-targetSdk = "35" -androidx-activityCompose = "1.10.1" -androidx-appcompat = "1.7.0" -androidx-constraintlayout = "2.2.1" -androidx-core-ktx = "1.16.0" -androidx-espresso-core = "3.6.1" -androidx-lifecycle = "2.8.4" -androidx-material = "1.12.0" -androidx-test-junit = "1.2.1" -apollo = "4.2.0" -compose-multiplatform = "1.8.0" -composeRememberSetting = "1.0.2" -filekitCore = "0.8.8" -junit = "4.13.2" -jwtKt = "1.1.0" -koin = "4.0.2" -kotlin = "2.1.20" -kotlinx-coroutines = "1.10.1" -kotlinDocumentStoreCore = "1.0.4-wasmjs" -kotlinxDatetime = "0.6.2" -ktor = "3.0.3" +android-targetSdk = "37" +androidx-activityCompose = "1.13.0" +androidx-lifecycle = "2.10.0" +apollo = "5.0.0" +compose-multiplatform = "1.11.1" +composeRememberSetting = "1.0.3" +filekit = "0.14.1" +jwtKt = "1.2.1" +koin = "4.2.1" +kotlin = "2.3.21" +kotlinx-coroutines = "1.11.0" +kotlinxDatetime = "0.8.0" +kotlinx-serialization-json = "1.11.0" +ktor = "3.5.0" +sketch = "4.4.0" usfmtools = "1.9.1" voyagerNavigator = "1.1.0-beta03" [libraries] -androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" } -androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-junit" } -androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso-core" } -androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } -androidx-material = { group = "com.google.android.material", name = "material", version.ref = "androidx-material" } -androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidx-constraintlayout" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } apollo-runtime = { module = "com.apollographql.apollo:apollo-runtime", version.ref = "apollo"} compose-remember-setting = { module = "dev.burnoo:compose-remember-setting", version.ref = "composeRememberSetting" } -filekit-compose = { module = "io.github.vinceglb:filekit-compose", version.ref = "filekitCore" } -filekit-core = { module = "io.github.vinceglb:filekit-core", version.ref = "filekitCore" } -junit = { group = "junit", name = "junit", version.ref = "junit" } +filekit-dialogs-compose = { module = "io.github.vinceglb:filekit-dialogs-compose", version.ref = "filekit" } +filekit-dialogs-core = { module = "io.github.vinceglb:filekit-dialogs", version.ref = "filekit" } jwt-kt = { module = "com.appstractive:jwt-kt", version.ref = "jwtKt" } koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" } koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" } @@ -50,15 +37,16 @@ koin-compose-viewmodel = { group = "io.insert-koin", name = "koin-compose-viewmo kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } -kotlin-document-store-core = { module = "io.github.mxaln:kotlin-document-store-core", version.ref = "kotlinDocumentStoreCore" } -kotlin-document-store-browser = { module = "io.github.mxaln:kotlin-document-store-browser", version.ref = "kotlinDocumentStoreCore" } -kotlin-document-store-leveldb = { module = "io.github.mxaln:kotlin-document-store-leveldb", version.ref = "kotlinDocumentStoreCore" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-serialization-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } +sketch-compose = { group = "io.github.panpf.sketch4", name = "sketch-compose", version.ref = "sketch" } +sketch-compose-resources = { group = "io.github.panpf.sketch4", name = "sketch-compose-resources", version.ref = "sketch" } +sketch-compose-gif = { group = "io.github.panpf.sketch4", name = "sketch-animated-gif", version.ref = "sketch" } usfmtools = { module = "org.wycliffeassociates:usfmtools", version.ref = "usfmtools" } voyager-koin = { module = "cafe.adriel.voyager:voyager-koin", version.ref = "voyagerNavigator" } voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyagerNavigator" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e2847c8..5dd3c01 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/kotlin-js-store/wasm/yarn.lock b/kotlin-js-store/wasm/yarn.lock new file mode 100644 index 0000000..244ae62 --- /dev/null +++ b/kotlin-js-store/wasm/yarn.lock @@ -0,0 +1,18 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@js-joda/core@3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273" + integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg== + +usfmtools@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/usfmtools/-/usfmtools-1.0.6.tgz#7201ab00a00494248e88720002f07096f905b684" + integrity sha512-VsFOg//OEqdS2lOi7QuV4jN4C5clh7Hw91zCnRKpBjz1pS0mpTiQQknQwnjix1H9IZO5kln/VGYlnJ8OICtaww== + +ws@8.18.3: + version "8.18.3" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472" + integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg== diff --git a/settings.gradle.kts b/settings.gradle.kts index f569a57..8e9d1ae 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -14,6 +14,9 @@ pluginManagement { gradlePluginPortal() } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} dependencyResolutionManagement { repositories {