diff --git a/apps/api/drizzle-sqlite-legacy/meta/0000_snapshot.json b/apps/api/drizzle-sqlite-legacy/meta/0000_snapshot.json index 88f526a9..54abc0aa 100644 --- a/apps/api/drizzle-sqlite-legacy/meta/0000_snapshot.json +++ b/apps/api/drizzle-sqlite-legacy/meta/0000_snapshot.json @@ -1,67 +1,54 @@ { - "id": "b83e1f2a-9c4d-4e7b-a1f3-8d2c6b5a4e90", - "prevId": "47a9d637-64e3-4cca-a916-cf42ea63b335", - "version": "7", - "dialect": "postgresql", + "version": "6", + "dialect": "sqlite", + "id": "c7909605-aabf-4832-8ef8-9390c0f7c99a", + "prevId": "00000000-0000-0000-0000-000000000000", "tables": { - "public.api_keys": { + "api_keys": { "name": "api_keys", - "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true + "notNull": true, + "autoincrement": false }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "key_hash": { "name": "key_hash", "type": "text", "primaryKey": false, - "notNull": true - }, - "key_prefix": { - "name": "key_prefix", - "type": "text", - "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, "name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": "'Default API Key'" }, - "permissions": { - "name": "permissions", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "last_used_at": { "name": "last_used_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false } }, "indexes": {}, @@ -78,423 +65,120 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.audit_log": { - "name": "audit_log", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "actor_id": { - "name": "actor_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "actor_username": { - "name": "actor_username", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "action": { - "name": "action", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "target_type": { - "name": "target_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "target_id": { - "name": "target_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "details": { - "name": "details", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "ip_address": { - "name": "ip_address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "audit_log_actor_id_users_id_fk": { - "name": "audit_log_actor_id_users_id_fk", - "tableFrom": "audit_log", - "tableTo": "users", - "columnsFrom": ["actor_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.jobs": { + "jobs": { "name": "jobs", - "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "tool_id": { - "name": "tool_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "pool": { - "name": "pool", - "type": "text", - "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, "type": { "name": "type", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "status": { "name": "status", - "type": "job_status", - "typeSchema": "public", + "type": "text", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": "'queued'" }, - "attempts": { - "name": "attempts", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, "progress": { "name": "progress", - "type": "jsonb", + "type": "real", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false, + "default": 0 }, - "input_refs": { - "name": "input_refs", - "type": "jsonb", + "input_files": { + "name": "input_files", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, - "output_refs": { - "name": "output_refs", - "type": "jsonb", + "output_path": { + "name": "output_path", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "settings": { "name": "settings", - "type": "jsonb", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "error": { "name": "error", - "type": "jsonb", + "type": "text", "primaryKey": false, - "notNull": false - }, - "bytes_in": { - "name": "bytes_in", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "bytes_out": { - "name": "bytes_out", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "duration_ms": { - "name": "duration_ms", - "type": "integer", - "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": true - }, - "started_at": { - "name": "started_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, "completed_at": { "name": "completed_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "jobs_created_at_idx": { - "name": "jobs_created_at_idx", - "columns": [ - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "jobs_status_idx": { - "name": "jobs_status_idx", - "columns": [ - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "jobs_user_id_users_id_fk": { - "name": "jobs_user_id_users_id_fk", - "tableFrom": "jobs", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.pipelines": { - "name": "pipelines", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "steps": { - "name": "steps", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true + "notNull": false, + "autoincrement": false } }, "indexes": {}, - "foreignKeys": { - "pipelines_user_id_users_id_fk": { - "name": "pipelines_user_id_users_id_fk", - "tableFrom": "pipelines", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, + "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.roles": { - "name": "roles", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "''" - }, - "permissions": { - "name": "permissions", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "is_builtin": { - "name": "is_builtin", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_by": { - "name": "created_by", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "roles_created_by_users_id_fk": { - "name": "roles_created_by_users_id_fk", - "tableFrom": "roles", - "tableTo": "users", - "columnsFrom": ["created_by"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "roles_name_unique": { - "name": "roles_name_unique", - "nullsNotDistinct": false, - "columns": ["name"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.sessions": { + "sessions": { "name": "sessions", - "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true + "notNull": true, + "autoincrement": false }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "expires_at": { "name": "expires_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": true - }, - "id_token": { - "name": "id_token", - "type": "text", - "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false } }, "indexes": {}, @@ -511,297 +195,115 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.settings": { + "settings": { "name": "settings", - "schema": "", "columns": { "key": { "name": "key", "type": "text", "primaryKey": true, - "notNull": true + "notNull": true, + "autoincrement": false }, "value": { "name": "value", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "updated_at": { "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.teams": { - "name": "teams", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "teams_name_unique": { - "name": "teams_name_unique", - "nullsNotDistinct": false, - "columns": ["name"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user_files": { - "name": "user_files", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "original_name": { - "name": "original_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "stored_name": { - "name": "stored_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "mime_type": { - "name": "mime_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "size": { - "name": "size", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "width": { - "name": "width", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "height": { - "name": "height", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "version": { - "name": "version", "type": "integer", "primaryKey": false, "notNull": true, - "default": 1 - }, - "parent_id": { - "name": "parent_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "tool_chain": { - "name": "tool_chain", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true + "autoincrement": false } }, "indexes": {}, - "foreignKeys": { - "user_files_user_id_users_id_fk": { - "name": "user_files_user_id_users_id_fk", - "tableFrom": "user_files", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, + "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.users": { + "users": { "name": "users", - "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true + "notNull": true, + "autoincrement": false }, "username": { "name": "username", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "password_hash": { "name": "password_hash", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, "role": { "name": "role", "type": "text", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": "'user'" }, - "team": { - "name": "team", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'Default'" - }, "must_change_password": { "name": "must_change_password", - "type": "boolean", + "type": "integer", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": true }, - "auth_provider": { - "name": "auth_provider", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'local'" - }, - "external_id": { - "name": "external_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": false - }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "updated_at": { "name": "updated_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": true - }, - "analytics_enabled": { - "name": "analytics_enabled", - "type": "boolean", - "primaryKey": false, - "notNull": false - }, - "analytics_consent_shown_at": { - "name": "analytics_consent_shown_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "analytics_consent_remind_at": { - "name": "analytics_consent_remind_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false } }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { + "indexes": { "users_username_unique": { "name": "users_username_unique", - "nullsNotDistinct": false, - "columns": ["username"] + "columns": ["username"], + "isUnique": true } }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} } }, - "enums": { - "public.job_status": { - "name": "job_status", - "schema": "public", - "values": ["queued", "processing", "completed", "failed", "canceled"] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, "views": {}, + "enums": {}, "_meta": { - "columns": {}, "schemas": {}, - "tables": {} + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} } } diff --git a/apps/api/drizzle-sqlite-legacy/meta/0001_snapshot.json b/apps/api/drizzle-sqlite-legacy/meta/0001_snapshot.json index 6bf86838..88f526a9 100644 --- a/apps/api/drizzle-sqlite-legacy/meta/0001_snapshot.json +++ b/apps/api/drizzle-sqlite-legacy/meta/0001_snapshot.json @@ -1,54 +1,67 @@ { - "version": "6", - "dialect": "sqlite", - "id": "91a14a95-bbcb-46ef-abe3-6d2f6fbc8458", - "prevId": "c7909605-aabf-4832-8ef8-9390c0f7c99a", + "id": "b83e1f2a-9c4d-4e7b-a1f3-8d2c6b5a4e90", + "prevId": "47a9d637-64e3-4cca-a916-cf42ea63b335", + "version": "7", + "dialect": "postgresql", "tables": { - "api_keys": { + "public.api_keys": { "name": "api_keys", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "key_hash": { "name": "key_hash", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false }, "name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true, - "autoincrement": false, "default": "'Default API Key'" }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "created_at": { "name": "created_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "last_used_at": { "name": "last_used_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false } }, "indexes": {}, @@ -65,165 +78,423 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "jobs": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_username": { + "name": "actor_username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jobs": { "name": "jobs", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'queued'" - }, - "progress": { - "name": "progress", - "type": "real", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "input_files": { - "name": "input_files", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "output_path": { - "name": "output_path", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "settings": { - "name": "settings", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "error": { - "name": "error", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "completed_at": { - "name": "completed_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "pipelines": { - "name": "pipelines", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "steps": { - "name": "steps", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "sessions": { - "name": "sessions", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": false }, - "expires_at": { - "name": "expires_at", + "tool_id": { + "name": "tool_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pool": { + "name": "pool", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", "type": "integer", "primaryKey": false, "notNull": true, - "autoincrement": false + "default": 0 + }, + "progress": { + "name": "progress", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "input_refs": { + "name": "input_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "output_refs": { + "name": "output_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "bytes_in": { + "name": "bytes_in", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "bytes_out": { + "name": "bytes_out", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false }, "created_at": { "name": "created_at", - "type": "integer", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "jobs_created_at_idx": { + "name": "jobs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "jobs_status_idx": { + "name": "jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "jobs_user_id_users_id_fk": { + "name": "jobs_user_id_users_id_fk", + "tableFrom": "jobs", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipelines": { + "name": "pipelines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steps": { + "name": "steps", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "pipelines_user_id_users_id_fk": { + "name": "pipelines_user_id_users_id_fk", + "tableFrom": "pipelines", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", "primaryKey": false, "notNull": true, - "autoincrement": false + "default": "''" + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "is_builtin": { + "name": "is_builtin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "roles_created_by_users_id_fk": { + "name": "roles_created_by_users_id_fk", + "tableFrom": "roles", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true } }, "indexes": {}, @@ -240,115 +511,297 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "settings": { + "public.settings": { "name": "settings", + "schema": "", "columns": { "key": { "name": "key", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, "value": { "name": "value", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "updated_at": { "name": "updated_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true } }, "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "users": { - "name": "users", + "public.teams": { + "name": "teams", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_name_unique": { + "name": "teams_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_files": { + "name": "user_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stored_name": { + "name": "stored_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, "notNull": true, - "autoincrement": false + "default": 1 + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_chain": { + "name": "tool_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_files_user_id_users_id_fk": { + "name": "user_files_user_id_users_id_fk", + "tableFrom": "user_files", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true }, "username": { "name": "username", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "password_hash": { "name": "password_hash", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": false }, "role": { "name": "role", "type": "text", "primaryKey": false, "notNull": true, - "autoincrement": false, "default": "'user'" }, + "team": { + "name": "team", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Default'" + }, "must_change_password": { "name": "must_change_password", - "type": "integer", + "type": "boolean", "primaryKey": false, "notNull": true, - "autoincrement": false, "default": true }, + "auth_provider": { + "name": "auth_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local'" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, "created_at": { "name": "created_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "updated_at": { "name": "updated_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "users_username_unique": { - "name": "users_username_unique", - "columns": ["username"], - "isUnique": true + "notNull": true + }, + "analytics_enabled": { + "name": "analytics_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "analytics_consent_shown_at": { + "name": "analytics_consent_shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "analytics_consent_remind_at": { + "name": "analytics_consent_remind_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false } }, + "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false } }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} + "enums": { + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": ["queued", "processing", "completed", "failed", "canceled"] + } }, - "internal": { - "indexes": {} + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} } } diff --git a/apps/api/package.json b/apps/api/package.json index bac78cb2..7a08bdd4 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -55,6 +55,7 @@ "sharp": "^0.34.5", "tar": "^7.5.16", "tsx": "^4.22.4", + "yauzl": "^3.4.0", "zod": "^3.24.0", "zxing-wasm": "^3.1.0" }, @@ -70,6 +71,7 @@ "@types/potrace": "^2.1.5", "@types/qrcode": "^1.5.6", "@types/tar": "^7.0.87", + "@types/yauzl": "^3.3.0", "drizzle-kit": "^0.31.0", "typescript": "^5.7.0" }, diff --git a/apps/api/src/routes/tools/convert-document.ts b/apps/api/src/routes/tools/convert-document.ts new file mode 100644 index 00000000..1a673071 --- /dev/null +++ b/apps/api/src/routes/tools/convert-document.ts @@ -0,0 +1,57 @@ +import { writeFile } from "node:fs/promises"; +import { extname, join } from "node:path"; +import { convertDocument } from "@snapotter/doc-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { env } from "../../config.js"; +import { InputValidationError } from "../../modality/contract.js"; +import { createToolRoute } from "../tool-factory.js"; + +const CONTENT_TYPES: Record = { + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + odt: "application/vnd.oasis.opendocument.text", + rtf: "application/rtf", + txt: "text/plain", +}; + +const settingsSchema = z.object({ + format: z.enum(["docx", "odt", "rtf", "txt"]), +}); + +export function registerConvertDocument(app: FastifyInstance) { + createToolRoute(app, { + toolId: "convert-document", + settingsSchema, + process: async () => { + throw new Error("convert-document is v2-only"); + }, + processV2: async (ctx) => { + const settings = ctx.settings as z.infer; + const input = ctx.inputs[0]; + + // Reject same-format no-ops + const inputExt = extname(input.filename).toLowerCase(); + if (inputExt === `.${settings.format}`) { + throw new InputValidationError("The file is already in that format", 422); + } + + const base = input.filename.replace(/\.[^.]+$/, ""); + // Preserve the real extension so LibreOffice can sniff the input format. + const sanitized = input.filename.replace(/[^A-Za-z0-9._-]/g, "_"); + const inPath = join(ctx.scratchDir, `in-${sanitized}`); + await writeFile(inPath, input.buffer); + + ctx.report(10, "Converting"); + const outPath = await convertDocument(inPath, ctx.scratchDir, settings.format, { + timeoutMs: (env.LIBREOFFICE_TIMEOUT_S || 120) * 1000, + }); + ctx.report(90, "Done"); + + return { + scratchPath: outPath, + filename: `${base}.${settings.format}`, + contentType: CONTENT_TYPES[settings.format], + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/convert-presentation.ts b/apps/api/src/routes/tools/convert-presentation.ts new file mode 100644 index 00000000..c1ef6505 --- /dev/null +++ b/apps/api/src/routes/tools/convert-presentation.ts @@ -0,0 +1,55 @@ +import { writeFile } from "node:fs/promises"; +import { extname, join } from "node:path"; +import { convertDocument } from "@snapotter/doc-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { env } from "../../config.js"; +import { InputValidationError } from "../../modality/contract.js"; +import { createToolRoute } from "../tool-factory.js"; + +const CONTENT_TYPES: Record = { + pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", + odp: "application/vnd.oasis.opendocument.presentation", +}; + +const settingsSchema = z.object({ + format: z.enum(["pptx", "odp"]), +}); + +export function registerConvertPresentation(app: FastifyInstance) { + createToolRoute(app, { + toolId: "convert-presentation", + settingsSchema, + process: async () => { + throw new Error("convert-presentation is v2-only"); + }, + processV2: async (ctx) => { + const settings = ctx.settings as z.infer; + const input = ctx.inputs[0]; + + // Reject same-format no-ops + const inputExt = extname(input.filename).toLowerCase(); + if (inputExt === `.${settings.format}`) { + throw new InputValidationError("The file is already in that format", 422); + } + + const base = input.filename.replace(/\.[^.]+$/, ""); + // Preserve the real extension so LibreOffice can sniff the input format. + const sanitized = input.filename.replace(/[^A-Za-z0-9._-]/g, "_"); + const inPath = join(ctx.scratchDir, `in-${sanitized}`); + await writeFile(inPath, input.buffer); + + ctx.report(10, "Converting"); + const outPath = await convertDocument(inPath, ctx.scratchDir, settings.format, { + timeoutMs: (env.LIBREOFFICE_TIMEOUT_S || 120) * 1000, + }); + ctx.report(90, "Done"); + + return { + scratchPath: outPath, + filename: `${base}.${settings.format}`, + contentType: CONTENT_TYPES[settings.format], + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/convert-spreadsheet.ts b/apps/api/src/routes/tools/convert-spreadsheet.ts new file mode 100644 index 00000000..9979dfb3 --- /dev/null +++ b/apps/api/src/routes/tools/convert-spreadsheet.ts @@ -0,0 +1,56 @@ +import { writeFile } from "node:fs/promises"; +import { extname, join } from "node:path"; +import { convertDocument } from "@snapotter/doc-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { env } from "../../config.js"; +import { InputValidationError } from "../../modality/contract.js"; +import { createToolRoute } from "../tool-factory.js"; + +const CONTENT_TYPES: Record = { + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ods: "application/vnd.oasis.opendocument.spreadsheet", + csv: "text/csv", +}; + +const settingsSchema = z.object({ + format: z.enum(["xlsx", "ods", "csv"]), +}); + +export function registerConvertSpreadsheet(app: FastifyInstance) { + createToolRoute(app, { + toolId: "convert-spreadsheet", + settingsSchema, + process: async () => { + throw new Error("convert-spreadsheet is v2-only"); + }, + processV2: async (ctx) => { + const settings = ctx.settings as z.infer; + const input = ctx.inputs[0]; + + // Reject same-format no-ops + const inputExt = extname(input.filename).toLowerCase(); + if (inputExt === `.${settings.format}`) { + throw new InputValidationError("The file is already in that format", 422); + } + + const base = input.filename.replace(/\.[^.]+$/, ""); + // Preserve the real extension so LibreOffice can sniff the input format. + const sanitized = input.filename.replace(/[^A-Za-z0-9._-]/g, "_"); + const inPath = join(ctx.scratchDir, `in-${sanitized}`); + await writeFile(inPath, input.buffer); + + ctx.report(10, "Converting"); + const outPath = await convertDocument(inPath, ctx.scratchDir, settings.format, { + timeoutMs: (env.LIBREOFFICE_TIMEOUT_S || 120) * 1000, + }); + ctx.report(90, "Done"); + + return { + scratchPath: outPath, + filename: `${base}.${settings.format}`, + contentType: CONTENT_TYPES[settings.format], + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/create-zip.ts b/apps/api/src/routes/tools/create-zip.ts new file mode 100644 index 00000000..2fda3b2f --- /dev/null +++ b/apps/api/src/routes/tools/create-zip.ts @@ -0,0 +1,62 @@ +import { createWriteStream } from "node:fs"; +import { extname, join } from "node:path"; +import archiver from "archiver"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { InputValidationError } from "../../modality/contract.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({}); + +export function registerCreateZip(app: FastifyInstance) { + createToolRoute(app, { + toolId: "create-zip", + maxInputs: 50, + settingsSchema, + process: async () => { + throw new Error("create-zip is v2-only"); + }, + processV2: async (ctx) => { + if (ctx.inputs.length < 2) { + throw new InputValidationError("Zipping needs at least two files"); + } + + // Deduplicate filenames: name-1.ext, name-2.ext on collision + const usedNames = new Map(); + const entryNames: string[] = []; + for (const input of ctx.inputs) { + const ext = extname(input.filename); + const base = input.filename.slice(0, input.filename.length - ext.length) || "file"; + const key = input.filename.toLowerCase(); + const count = usedNames.get(key) ?? 0; + if (count === 0) { + entryNames.push(input.filename); + } else { + entryNames.push(`${base}-${count}${ext}`); + } + usedNames.set(key, count + 1); + } + + const zipPath = join(ctx.scratchDir, "archive.zip"); + await new Promise((resolve, reject) => { + const output = createWriteStream(zipPath); + const archive = archiver("zip", { zlib: { level: 6 } }); + output.on("close", () => resolve()); + archive.on("error", (err: Error) => reject(err)); + archive.pipe(output); + for (let i = 0; i < ctx.inputs.length; i++) { + archive.append(ctx.inputs[i].buffer, { name: entryNames[i] }); + const pct = Math.min(90, 10 + Math.round(((i + 1) / ctx.inputs.length) * 80)); + ctx.report(pct, `Adding file ${i + 1} of ${ctx.inputs.length}`); + } + void archive.finalize(); + }); + + return { + scratchPath: zipPath, + filename: "archive.zip", + contentType: "application/zip", + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/epub-convert.ts b/apps/api/src/routes/tools/epub-convert.ts new file mode 100644 index 00000000..29028419 --- /dev/null +++ b/apps/api/src/routes/tools/epub-convert.ts @@ -0,0 +1,65 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { htmlToPdfPy, runPandoc } from "@snapotter/doc-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { createToolRoute } from "../tool-factory.js"; + +const CONTENT_TYPES: Record = { + pdf: "application/pdf", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + html: "text/html", + md: "text/markdown", +}; + +const settingsSchema = z.object({ + format: z.enum(["pdf", "docx", "html", "md"]), +}); + +export function registerEpubConvert(app: FastifyInstance) { + createToolRoute(app, { + toolId: "epub-convert", + settingsSchema, + process: async () => { + throw new Error("epub-convert is v2-only"); + }, + processV2: async (ctx) => { + const settings = ctx.settings as z.infer; + const input = ctx.inputs[0]; + const base = input.filename.replace(/\.[^.]+$/, ""); + const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`); + await writeFile(inPath, input.buffer); + + const { format } = settings; + ctx.report(10, "Converting"); + + if (format === "pdf") { + // Two-step chain: epub -> standalone HTML -> PDF via WeasyPrint. + // Resource embedding MUST stay OFF: pandoc --self-contained / --embed-resources + // would fetch remote refs server-side (SSRF). The weasyprint bridge pre-scan + // enforces remote-ref rejection so the PDF path fails safely on remote content. + const intermediateHtml = join(ctx.scratchDir, "book.html"); + await runPandoc(inPath, intermediateHtml, { extraArgs: ["--standalone"] }); + const outPath = join(ctx.scratchDir, `${base}.pdf`); + await htmlToPdfPy(intermediateHtml, outPath, "html"); + ctx.report(90, "Done"); + return { + scratchPath: outPath, + filename: `${base}.pdf`, + contentType: CONTENT_TYPES.pdf, + }; + } + + // Direct pandoc conversion for docx, html, md + const outPath = join(ctx.scratchDir, `${base}.${format}`); + await runPandoc(inPath, outPath, format === "html" ? { extraArgs: ["--standalone"] } : {}); + ctx.report(90, "Done"); + + return { + scratchPath: outPath, + filename: `${base}.${format}`, + contentType: CONTENT_TYPES[format], + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/excel-to-pdf.ts b/apps/api/src/routes/tools/excel-to-pdf.ts new file mode 100644 index 00000000..654743d9 --- /dev/null +++ b/apps/api/src/routes/tools/excel-to-pdf.ts @@ -0,0 +1,40 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { convertDocument } from "@snapotter/doc-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { env } from "../../config.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({}); + +export function registerExcelToPdf(app: FastifyInstance) { + createToolRoute(app, { + toolId: "excel-to-pdf", + settingsSchema, + process: async () => { + throw new Error("excel-to-pdf is v2-only"); + }, + processV2: async (ctx) => { + const input = ctx.inputs[0]; + const base = input.filename.replace(/\.[^.]+$/, ""); + // Sanitize the basename but keep the real extension so LibreOffice + // can sniff the input format (e.g. .xlsx vs .ods vs .csv). + const sanitized = input.filename.replace(/[^A-Za-z0-9._-]/g, "_"); + const inPath = join(ctx.scratchDir, `in-${sanitized}`); + await writeFile(inPath, input.buffer); + + ctx.report(10, "Converting"); + const outPath = await convertDocument(inPath, ctx.scratchDir, "pdf", { + timeoutMs: (env.LIBREOFFICE_TIMEOUT_S || 120) * 1000, + }); + ctx.report(90, "Done"); + + return { + scratchPath: outPath, + filename: `${base}.pdf`, + contentType: "application/pdf", + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/extract-zip.ts b/apps/api/src/routes/tools/extract-zip.ts new file mode 100644 index 00000000..4dd8cdb1 --- /dev/null +++ b/apps/api/src/routes/tools/extract-zip.ts @@ -0,0 +1,196 @@ +import { createWriteStream } from "node:fs"; +import { basename, extname, join } from "node:path"; +import archiver from "archiver"; +import type { FastifyInstance } from "fastify"; +import { type Entry, fromBuffer, type ZipFile } from "yauzl"; +import { z } from "zod"; +import { InputValidationError } from "../../modality/contract.js"; +import { createToolRoute } from "../tool-factory.js"; + +/** Promise wrapper for yauzl.fromBuffer with lazyEntries. */ +function openZipBuffer(buffer: Buffer): Promise { + return new Promise((resolve, reject) => { + fromBuffer(buffer, { lazyEntries: true, validateEntrySizes: true }, (err, zipfile) => { + if (err) return reject(err); + if (!zipfile) return reject(new Error("Failed to open zip")); + resolve(zipfile); + }); + }); +} + +const MAX_ENTRIES = 1000; +const MAX_ENTRY_SIZE = 200 * 1024 * 1024; // 200 MiB +const MAX_TOTAL_SIZE = 500 * 1024 * 1024; // 500 MiB +const MAX_RATIO = 100; +const PAYLOAD_ENTRY_CAP = 100; + +const settingsSchema = z.object({}); + +/** Read all entries from a yauzl ZipFile (lazyEntries mode). */ +function collectEntries(zipfile: ZipFile): Promise { + return new Promise((resolve, reject) => { + const entries: Entry[] = []; + zipfile.on("entry", (entry: Entry) => { + entries.push(entry); + zipfile.readEntry(); + }); + zipfile.on("end", () => resolve(entries)); + zipfile.on("error", (err: Error) => reject(err)); + zipfile.readEntry(); + }); +} + +/** Read an entry stream into a Buffer. */ +function readEntryBuffer(zipfile: ZipFile, entry: Entry): Promise { + return new Promise((resolve, reject) => { + zipfile.openReadStream(entry, (err, stream) => { + if (err) return reject(err); + const chunks: Buffer[] = []; + stream.on("data", (chunk: Buffer) => chunks.push(chunk)); + stream.on("end", () => resolve(Buffer.concat(chunks))); + stream.on("error", (e: Error) => reject(e)); + }); + }); +} + +/** Deduplicate basenames: name-1.ext, name-2.ext on collision. */ +function deduplicateNames(names: string[]): string[] { + const usedNames = new Map(); + const result: string[] = []; + for (const raw of names) { + const name = basename(raw); + const ext = extname(name); + const base = name.slice(0, name.length - ext.length) || "file"; + const key = name.toLowerCase(); + const count = usedNames.get(key) ?? 0; + if (count === 0) { + result.push(name); + } else { + result.push(`${base}-${count}${ext}`); + } + usedNames.set(key, count + 1); + } + return result; +} + +export function registerExtractZip(app: FastifyInstance) { + createToolRoute(app, { + toolId: "extract-zip", + settingsSchema, + process: async () => { + throw new Error("extract-zip is v2-only"); + }, + processV2: async (ctx) => { + const input = ctx.inputs[0]; + const inputBase = input.filename.replace(/\.[^.]+$/, "") || "archive"; + const zipSize = input.buffer.length; + + ctx.report(5, "Reading archive"); + const zipfile = await openZipBuffer(input.buffer); + + const allEntries = await collectEntries(zipfile); + + // Filter out directories and symlinks + const fileEntries: Entry[] = []; + for (const entry of allEntries) { + // Skip directory entries (name ends with /) + if (entry.fileName.endsWith("/")) continue; + // Skip symlinks: Unix external attrs mode check + const mode = (entry.externalFileAttributes >>> 16) & 0o170000; + if (mode === 0o120000) continue; + fileEntries.push(entry); + } + + // Guard: entry count + if (fileEntries.length > MAX_ENTRIES) { + throw new InputValidationError("Too many entries"); + } + + // Guard: per-entry size, total size, path safety + let totalUncompressed = 0; + for (const entry of fileEntries) { + if (entry.uncompressedSize > MAX_ENTRY_SIZE) { + throw new InputValidationError("Archive expands too large"); + } + totalUncompressed += entry.uncompressedSize; + if (totalUncompressed > MAX_TOTAL_SIZE) { + throw new InputValidationError("Archive expands too large"); + } + + // Path safety: reject absolute paths and ".." segments + const name = entry.fileName; + if (name.startsWith("/") || name.startsWith("\\")) { + throw new InputValidationError("Unsafe entry path"); + } + const segments = name.split(/[/\\]/); + if (segments.some((s) => s === "..")) { + throw new InputValidationError("Unsafe entry path"); + } + } + + // Guard: compression ratio + if (zipSize > 0 && totalUncompressed / zipSize > MAX_RATIO) { + throw new InputValidationError("Suspicious compression ratio"); + } + + ctx.report(20, "Extracting files"); + + // Build the entries payload (capped at 100) + const entriesPayload = fileEntries.slice(0, PAYLOAD_ENTRY_CAP).map((e) => ({ + name: basename(e.fileName), + size: e.uncompressedSize, + })); + + // Re-open for streaming extraction (yauzl consumed the entries) + const zipfile2 = await openZipBuffer(input.buffer); + const allEntries2 = await collectEntries(zipfile2); + const fileEntries2 = allEntries2.filter((entry) => { + if (entry.fileName.endsWith("/")) return false; + const mode = (entry.externalFileAttributes >>> 16) & 0o170000; + if (mode === 0o120000) return false; + return true; + }); + + // Single file: output the bare file directly + if (fileEntries2.length === 1) { + const entry = fileEntries2[0]; + const buf = await readEntryBuffer(zipfile2, entry); + ctx.report(90, "Done"); + return { + buffer: buf, + filename: basename(entry.fileName), + contentType: "application/octet-stream", + resultPayload: { entries: entriesPayload }, + }; + } + + // Multiple files: repackage flat + const dedupedNames = deduplicateNames(fileEntries2.map((e) => e.fileName)); + const outPath = join(ctx.scratchDir, `${inputBase}_extracted.zip`); + const archive = archiver("zip", { zlib: { level: 6 } }); + const output = createWriteStream(outPath); + archive.pipe(output); + + for (let i = 0; i < fileEntries2.length; i++) { + const buf = await readEntryBuffer(zipfile2, fileEntries2[i]); + archive.append(buf, { name: dedupedNames[i] }); + const pct = Math.min(85, 30 + Math.round(((i + 1) / fileEntries2.length) * 55)); + ctx.report(pct, `Extracting file ${i + 1} of ${fileEntries2.length}`); + } + + await new Promise((resolve, reject) => { + output.on("close", () => resolve()); + archive.on("error", (err: Error) => reject(err)); + void archive.finalize(); + }); + + ctx.report(95, "Done"); + return { + scratchPath: outPath, + filename: `${inputBase}_extracted.zip`, + contentType: "application/zip", + resultPayload: { entries: entriesPayload }, + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/index.ts b/apps/api/src/routes/tools/index.ts index 50815a62..b3d1c12c 100644 --- a/apps/api/src/routes/tools/index.ts +++ b/apps/api/src/routes/tools/index.ts @@ -29,7 +29,11 @@ import { registerCompressVideo } from "./compress-video.js"; import { registerContentAwareResize } from "./content-aware-resize.js"; import { registerConvert } from "./convert.js"; import { registerConvertAudio } from "./convert-audio.js"; +import { registerConvertDocument } from "./convert-document.js"; +import { registerConvertPresentation } from "./convert-presentation.js"; +import { registerConvertSpreadsheet } from "./convert-spreadsheet.js"; import { registerConvertVideo } from "./convert-video.js"; +import { registerCreateZip } from "./create-zip.js"; import { registerCrop } from "./crop.js"; import { registerCropPdf } from "./crop-pdf.js"; import { registerCropVideo } from "./crop-video.js"; @@ -38,10 +42,13 @@ import { registerCsvJson } from "./csv-json.js"; import { registerEditMetadata } from "./edit-metadata.js"; import { registerEmbedSubtitles } from "./embed-subtitles.js"; import { registerEnhanceFaces } from "./enhance-faces.js"; +import { registerEpubConvert } from "./epub-convert.js"; import { registerEraseObject } from "./erase-object.js"; +import { registerExcelToPdf } from "./excel-to-pdf.js"; import { registerExtractAudio } from "./extract-audio.js"; import { registerExtractPages } from "./extract-pages.js"; import { registerExtractSubtitles } from "./extract-subtitles.js"; +import { registerExtractZip } from "./extract-zip.js"; import { registerFadeAudio } from "./fade-audio.js"; import { registerFavicon } from "./favicon.js"; import { registerFindDuplicates } from "./find-duplicates.js"; @@ -58,9 +65,12 @@ import { registerImagesToVideo } from "./images-to-video.js"; import { registerInfo } from "./info.js"; import { registerJsonXml } from "./json-xml.js"; import { registerLinearizePdf } from "./linearize-pdf.js"; +import { registerMarkdownToDocx } from "./markdown-to-docx.js"; +import { registerMarkdownToHtml } from "./markdown-to-html.js"; import { registerMarkdownToPdf } from "./markdown-to-pdf.js"; import { registerMemeGenerator } from "./meme-generator.js"; import { registerMergeAudio } from "./merge-audio.js"; +import { registerMergeCsvs } from "./merge-csvs.js"; import { registerMergePdf } from "./merge-pdf.js"; import { registerMergeVideos } from "./merge-videos.js"; import { registerMuteVideo } from "./mute-video.js"; @@ -79,6 +89,7 @@ import { registerPdfToText } from "./pdf-to-text.js"; import { registerPdfToWord } from "./pdf-to-word.js"; import { registerPdfaConvert } from "./pdfa-convert.js"; import { registerPitchShift } from "./pitch-shift.js"; +import { registerPowerpointToPdf } from "./powerpoint-to-pdf.js"; import { registerProtectPdf } from "./protect-pdf.js"; import { registerQrGenerate } from "./qr-generate.js"; import { registerRedEyeRemoval } from "./red-eye-removal.js"; @@ -109,6 +120,7 @@ import { registerStitch } from "./stitch.js"; import { registerStripMetadata } from "./strip-metadata.js"; import { registerSvgToRaster } from "./svg-to-raster.js"; import { registerTextOverlay } from "./text-overlay.js"; +import { registerToEpub } from "./to-epub.js"; import { registerTransparencyFixer } from "./transparency-fixer.js"; import { registerTrimAudio } from "./trim-audio.js"; import { registerTrimVideo } from "./trim-video.js"; @@ -129,6 +141,8 @@ import { registerWatermarkText } from "./watermark-text.js"; import { registerWatermarkVideo } from "./watermark-video.js"; import { registerWaveformImage } from "./waveform-image.js"; import { registerWordToPdf } from "./word-to-pdf.js"; +import { registerXmlToCsv } from "./xml-to-csv.js"; +import { registerYamlJson } from "./yaml-json.js"; /** * Registry that imports and registers all tool routes. @@ -262,6 +276,11 @@ export async function registerToolRoutes(app: FastifyInstance): Promise { { id: "waveform-image", register: registerWaveformImage }, // PDF & Documents + { id: "convert-document", register: registerConvertDocument }, + { id: "convert-presentation", register: registerConvertPresentation }, + { id: "convert-spreadsheet", register: registerConvertSpreadsheet }, + { id: "epub-convert", register: registerEpubConvert }, + { id: "excel-to-pdf", register: registerExcelToPdf }, { id: "merge-pdf", register: registerMergePdf }, { id: "split-pdf", register: registerSplitPdf }, { id: "compress-pdf", register: registerCompressPdf }, @@ -286,14 +305,23 @@ export async function registerToolRoutes(app: FastifyInstance): Promise { { id: "pdf-to-text", register: registerPdfToText }, { id: "pdf-to-word", register: registerPdfToWord }, { id: "pdf-metadata", register: registerPdfMetadata }, + { id: "powerpoint-to-pdf", register: registerPowerpointToPdf }, { id: "html-to-pdf", register: registerHtmlToPdf }, + { id: "markdown-to-docx", register: registerMarkdownToDocx }, + { id: "markdown-to-html", register: registerMarkdownToHtml }, { id: "markdown-to-pdf", register: registerMarkdownToPdf }, + { id: "to-epub", register: registerToEpub }, // Data Files + { id: "create-zip", register: registerCreateZip }, { id: "csv-excel", register: registerCsvExcel }, { id: "csv-json", register: registerCsvJson }, + { id: "extract-zip", register: registerExtractZip }, { id: "json-xml", register: registerJsonXml }, + { id: "merge-csvs", register: registerMergeCsvs }, { id: "split-csv", register: registerSplitCsv }, + { id: "xml-to-csv", register: registerXmlToCsv }, + { id: "yaml-json", register: registerYamlJson }, // AI Tools { id: "remove-background", register: registerRemoveBackground }, diff --git a/apps/api/src/routes/tools/markdown-to-docx.ts b/apps/api/src/routes/tools/markdown-to-docx.ts new file mode 100644 index 00000000..ffb0bd13 --- /dev/null +++ b/apps/api/src/routes/tools/markdown-to-docx.ts @@ -0,0 +1,35 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { runPandoc } from "@snapotter/doc-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({}); + +export function registerMarkdownToDocx(app: FastifyInstance) { + createToolRoute(app, { + toolId: "markdown-to-docx", + settingsSchema, + process: async () => { + throw new Error("markdown-to-docx is v2-only"); + }, + processV2: async (ctx) => { + const input = ctx.inputs[0]; + const base = input.filename.replace(/\.[^.]+$/, ""); + const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`); + await writeFile(inPath, input.buffer); + + const outPath = join(ctx.scratchDir, `${base}.docx`); + ctx.report(10, "Converting"); + await runPandoc(inPath, outPath); + ctx.report(90, "Done"); + + return { + scratchPath: outPath, + filename: `${base}.docx`, + contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/markdown-to-html.ts b/apps/api/src/routes/tools/markdown-to-html.ts new file mode 100644 index 00000000..92ef6b92 --- /dev/null +++ b/apps/api/src/routes/tools/markdown-to-html.ts @@ -0,0 +1,35 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { runPandoc } from "@snapotter/doc-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({}); + +export function registerMarkdownToHtml(app: FastifyInstance) { + createToolRoute(app, { + toolId: "markdown-to-html", + settingsSchema, + process: async () => { + throw new Error("markdown-to-html is v2-only"); + }, + processV2: async (ctx) => { + const input = ctx.inputs[0]; + const base = input.filename.replace(/\.[^.]+$/, ""); + const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`); + await writeFile(inPath, input.buffer); + + const outPath = join(ctx.scratchDir, `${base}.html`); + ctx.report(10, "Converting"); + await runPandoc(inPath, outPath, { extraArgs: ["--standalone"] }); + ctx.report(90, "Done"); + + return { + scratchPath: outPath, + filename: `${base}.html`, + contentType: "text/html", + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/merge-csvs.ts b/apps/api/src/routes/tools/merge-csvs.ts new file mode 100644 index 00000000..6b6b6db1 --- /dev/null +++ b/apps/api/src/routes/tools/merge-csvs.ts @@ -0,0 +1,72 @@ +import type { FastifyInstance } from "fastify"; +import Papa from "papaparse"; +import { z } from "zod"; +import { InputValidationError } from "../../modality/contract.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({}); + +export function registerMergeCsvs(app: FastifyInstance) { + createToolRoute(app, { + toolId: "merge-csvs", + maxInputs: 20, + settingsSchema, + process: async () => { + throw new Error("merge-csvs is v2-only"); + }, + processV2: async (ctx) => { + if (ctx.inputs.length < 2) { + throw new InputValidationError("Merging needs at least two CSV files"); + } + + // Parse the first file to establish column order and delimiter + const firstText = ctx.inputs[0].buffer.toString("utf8"); + const firstResult = Papa.parse>(firstText, { + header: true, + skipEmptyLines: true, + }); + if (firstResult.errors.length > 0) { + throw new InputValidationError(`CSV parse failed: ${firstResult.errors[0].message}`); + } + + const fields = firstResult.meta.fields ?? []; + const fieldSet = new Set(fields); + const detectedDelimiter = firstResult.meta.delimiter || ","; + const allRows: Record[] = [...firstResult.data]; + + // Parse remaining files and validate headers match + for (let i = 1; i < ctx.inputs.length; i++) { + const input = ctx.inputs[i]; + const text = input.buffer.toString("utf8"); + const result = Papa.parse>(text, { + header: true, + skipEmptyLines: true, + }); + if (result.errors.length > 0) { + throw new InputValidationError( + `CSV parse failed in ${input.filename}: ${result.errors[0].message}`, + ); + } + + const otherFields = new Set(result.meta.fields ?? []); + if (otherFields.size !== fieldSet.size || ![...otherFields].every((f) => fieldSet.has(f))) { + throw new InputValidationError(`${input.filename} has different columns`); + } + + allRows.push(...result.data); + } + + // Unparse preserving the first file's field order and delimiter + const merged = Papa.unparse(allRows, { + columns: fields, + delimiter: detectedDelimiter, + }); + + return { + buffer: Buffer.from(merged, "utf8"), + filename: "merged.csv", + contentType: "text/csv", + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/powerpoint-to-pdf.ts b/apps/api/src/routes/tools/powerpoint-to-pdf.ts new file mode 100644 index 00000000..1c50cb9f --- /dev/null +++ b/apps/api/src/routes/tools/powerpoint-to-pdf.ts @@ -0,0 +1,40 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { convertDocument } from "@snapotter/doc-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { env } from "../../config.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({}); + +export function registerPowerpointToPdf(app: FastifyInstance) { + createToolRoute(app, { + toolId: "powerpoint-to-pdf", + settingsSchema, + process: async () => { + throw new Error("powerpoint-to-pdf is v2-only"); + }, + processV2: async (ctx) => { + const input = ctx.inputs[0]; + const base = input.filename.replace(/\.[^.]+$/, ""); + // Sanitize the basename but keep the real extension so LibreOffice + // can sniff the input format (e.g. .pptx vs .ppt vs .odp). + const sanitized = input.filename.replace(/[^A-Za-z0-9._-]/g, "_"); + const inPath = join(ctx.scratchDir, `in-${sanitized}`); + await writeFile(inPath, input.buffer); + + ctx.report(10, "Converting"); + const outPath = await convertDocument(inPath, ctx.scratchDir, "pdf", { + timeoutMs: (env.LIBREOFFICE_TIMEOUT_S || 120) * 1000, + }); + ctx.report(90, "Done"); + + return { + scratchPath: outPath, + filename: `${base}.pdf`, + contentType: "application/pdf", + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/to-epub.ts b/apps/api/src/routes/tools/to-epub.ts new file mode 100644 index 00000000..a4aaf476 --- /dev/null +++ b/apps/api/src/routes/tools/to-epub.ts @@ -0,0 +1,37 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { runPandoc } from "@snapotter/doc-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({}); + +export function registerToEpub(app: FastifyInstance) { + createToolRoute(app, { + toolId: "to-epub", + settingsSchema, + process: async () => { + throw new Error("to-epub is v2-only"); + }, + processV2: async (ctx) => { + const input = ctx.inputs[0]; + const base = input.filename.replace(/\.[^.]+$/, ""); + const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`); + await writeFile(inPath, input.buffer); + + const outPath = join(ctx.scratchDir, `${base}.epub`); + ctx.report(10, "Converting"); + await runPandoc(inPath, outPath, { + extraArgs: ["--metadata", `title=${base}`], + }); + ctx.report(90, "Done"); + + return { + scratchPath: outPath, + filename: `${base}.epub`, + contentType: "application/epub+zip", + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/xml-to-csv.ts b/apps/api/src/routes/tools/xml-to-csv.ts new file mode 100644 index 00000000..8390e73a --- /dev/null +++ b/apps/api/src/routes/tools/xml-to-csv.ts @@ -0,0 +1,90 @@ +import { XMLParser } from "fast-xml-parser"; +import type { FastifyInstance } from "fastify"; +import Papa from "papaparse"; +import { z } from "zod"; +import { InputValidationError } from "../../modality/contract.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({}); + +const TEN_MIB = 10 * 1024 * 1024; + +/** + * Walk the parsed XML tree depth-first and return the first value that + * is an array of objects (the "repeating elements" suitable for tabulation). + */ +function findFirstArray(node: unknown): Record[] | null { + if (Array.isArray(node)) { + if (node.length > 0 && typeof node[0] === "object" && node[0] !== null) { + return node as Record[]; + } + return null; + } + if (typeof node === "object" && node !== null) { + for (const val of Object.values(node)) { + const found = findFirstArray(val); + if (found) return found; + } + } + return null; +} + +/** + * Flatten one level: nested objects and arrays become JSON strings in the cell. + */ +function flattenRow(row: Record): Record { + const out: Record = {}; + for (const [key, val] of Object.entries(row)) { + if (val !== null && typeof val === "object") { + out[key] = JSON.stringify(val); + } else { + out[key] = val; + } + } + return out; +} + +export function registerXmlToCsv(app: FastifyInstance) { + createToolRoute(app, { + toolId: "xml-to-csv", + settingsSchema, + process: async () => { + throw new Error("xml-to-csv is v2-only"); + }, + processV2: async (ctx) => { + const input = ctx.inputs[0]; + const base = input.filename.replace(/\.[^.]+$/, ""); + + if (input.buffer.length > TEN_MIB) { + throw new InputValidationError("File too large for conversion (10 MB limit)"); + } + + const text = input.buffer.toString("utf8"); + + // Mirror json-xml.ts parser options + const parser = new XMLParser({ ignoreAttributes: false }); + let parsed: unknown; + try { + parsed = parser.parse(text); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + throw new InputValidationError(`XML parse failed: ${msg.split("\n")[0]}`); + } + + const rows = findFirstArray(parsed); + if (!rows || rows.length === 0) { + throw new InputValidationError("No repeating elements found to tabulate"); + } + + const flattened = rows.map(flattenRow); + const csv = Papa.unparse(flattened); + + return { + buffer: Buffer.from(csv, "utf8"), + filename: `${base}.csv`, + contentType: "text/csv", + resultPayload: { rows: flattened.length }, + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/yaml-json.ts b/apps/api/src/routes/tools/yaml-json.ts new file mode 100644 index 00000000..862b16d0 --- /dev/null +++ b/apps/api/src/routes/tools/yaml-json.ts @@ -0,0 +1,62 @@ +import type { FastifyInstance } from "fastify"; +import jsYaml from "js-yaml"; +import { z } from "zod"; +import { InputValidationError } from "../../modality/contract.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({}); + +const TEN_MIB = 10 * 1024 * 1024; + +export function registerYamlJson(app: FastifyInstance) { + createToolRoute(app, { + toolId: "yaml-json", + settingsSchema, + process: async () => { + throw new Error("yaml-json is v2-only"); + }, + processV2: async (ctx) => { + const input = ctx.inputs[0]; + const base = input.filename.replace(/\.[^.]+$/, ""); + const lower = input.filename.toLowerCase(); + + if (input.buffer.length > TEN_MIB) { + throw new InputValidationError("File too large for conversion (10 MB limit)"); + } + + const text = input.buffer.toString("utf8"); + + if (lower.endsWith(".json")) { + // JSON -> YAML + let data: unknown; + try { + data = JSON.parse(text); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + throw new InputValidationError(`Not valid JSON: ${msg.split("\n")[0]}`); + } + const yaml = jsYaml.dump(data); + return { + buffer: Buffer.from(yaml, "utf8"), + filename: `${base}.yaml`, + contentType: "text/yaml", + }; + } + + // YAML -> JSON (handles .yaml and .yml) + let parsed: unknown; + try { + parsed = jsYaml.load(text); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + throw new InputValidationError(`Not valid YAML: ${msg.split("\n")[0]}`); + } + const json = JSON.stringify(parsed, null, 2); + return { + buffer: Buffer.from(json, "utf8"), + filename: `${base}.json`, + contentType: "application/json", + }; + }, + }); +} diff --git a/apps/web/src/components/tools/convert-document-settings.tsx b/apps/web/src/components/tools/convert-document-settings.tsx new file mode 100644 index 00000000..9aa91860 --- /dev/null +++ b/apps/web/src/components/tools/convert-document-settings.tsx @@ -0,0 +1,74 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +type DocFormat = "docx" | "odt" | "rtf" | "txt"; + +export function ConvertDocumentSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["convert-document"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("convert-document"); + + const [outFormat, setOutFormat] = useState("odt"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { format: outFormat }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + +
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/convert-presentation-settings.tsx b/apps/web/src/components/tools/convert-presentation-settings.tsx new file mode 100644 index 00000000..8228b7f7 --- /dev/null +++ b/apps/web/src/components/tools/convert-presentation-settings.tsx @@ -0,0 +1,72 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +type PresFormat = "pptx" | "odp"; + +export function ConvertPresentationSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["convert-presentation"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("convert-presentation"); + + const [outFormat, setOutFormat] = useState("odp"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { format: outFormat }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + +
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/convert-spreadsheet-settings.tsx b/apps/web/src/components/tools/convert-spreadsheet-settings.tsx new file mode 100644 index 00000000..fdbff350 --- /dev/null +++ b/apps/web/src/components/tools/convert-spreadsheet-settings.tsx @@ -0,0 +1,75 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +type SheetFormat = "xlsx" | "ods" | "csv"; + +export function ConvertSpreadsheetSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["convert-spreadsheet"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("convert-spreadsheet"); + + const [outFormat, setOutFormat] = useState("ods"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { format: outFormat }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + +
+ +

{s.hint}

+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/create-zip-settings.tsx b/apps/web/src/components/tools/create-zip-settings.tsx new file mode 100644 index 00000000..05427a68 --- /dev/null +++ b/apps/web/src/components/tools/create-zip-settings.tsx @@ -0,0 +1,52 @@ +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function CreateZipSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["create-zip"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("create-zip"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = {}; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+ {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/epub-convert-settings.tsx b/apps/web/src/components/tools/epub-convert-settings.tsx new file mode 100644 index 00000000..8e995aca --- /dev/null +++ b/apps/web/src/components/tools/epub-convert-settings.tsx @@ -0,0 +1,76 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +type EpubFormat = "pdf" | "docx" | "html" | "md"; + +export function EpubConvertSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["epub-convert"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("epub-convert"); + + const [outFormat, setOutFormat] = useState("html"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { format: outFormat }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + +
+ +

{s.hint}

+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/excel-to-pdf-settings.tsx b/apps/web/src/components/tools/excel-to-pdf-settings.tsx new file mode 100644 index 00000000..6cb8d1f0 --- /dev/null +++ b/apps/web/src/components/tools/excel-to-pdf-settings.tsx @@ -0,0 +1,52 @@ +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function ExcelToPdfSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["excel-to-pdf"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("excel-to-pdf"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = {}; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+ {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/extract-zip-settings.tsx b/apps/web/src/components/tools/extract-zip-settings.tsx new file mode 100644 index 00000000..3d120351 --- /dev/null +++ b/apps/web/src/components/tools/extract-zip-settings.tsx @@ -0,0 +1,52 @@ +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function ExtractZipSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["extract-zip"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("extract-zip"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = {}; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+ {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/markdown-to-docx-settings.tsx b/apps/web/src/components/tools/markdown-to-docx-settings.tsx new file mode 100644 index 00000000..d354b329 --- /dev/null +++ b/apps/web/src/components/tools/markdown-to-docx-settings.tsx @@ -0,0 +1,52 @@ +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function MarkdownToDocxSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["markdown-to-docx"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("markdown-to-docx"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = {}; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+ {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/markdown-to-html-settings.tsx b/apps/web/src/components/tools/markdown-to-html-settings.tsx new file mode 100644 index 00000000..398d2fe3 --- /dev/null +++ b/apps/web/src/components/tools/markdown-to-html-settings.tsx @@ -0,0 +1,54 @@ +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function MarkdownToHtmlSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["markdown-to-html"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("markdown-to-html"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = {}; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+

{s.hint}

+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/merge-csvs-settings.tsx b/apps/web/src/components/tools/merge-csvs-settings.tsx new file mode 100644 index 00000000..6d0c3b39 --- /dev/null +++ b/apps/web/src/components/tools/merge-csvs-settings.tsx @@ -0,0 +1,52 @@ +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function MergeCsvsSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["merge-csvs"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("merge-csvs"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = {}; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+ {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/powerpoint-to-pdf-settings.tsx b/apps/web/src/components/tools/powerpoint-to-pdf-settings.tsx new file mode 100644 index 00000000..a825ba44 --- /dev/null +++ b/apps/web/src/components/tools/powerpoint-to-pdf-settings.tsx @@ -0,0 +1,52 @@ +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function PowerpointToPdfSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["powerpoint-to-pdf"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("powerpoint-to-pdf"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = {}; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+ {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/to-epub-settings.tsx b/apps/web/src/components/tools/to-epub-settings.tsx new file mode 100644 index 00000000..e9f896d3 --- /dev/null +++ b/apps/web/src/components/tools/to-epub-settings.tsx @@ -0,0 +1,52 @@ +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function ToEpubSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["to-epub"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("to-epub"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = {}; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+ {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/xml-to-csv-settings.tsx b/apps/web/src/components/tools/xml-to-csv-settings.tsx new file mode 100644 index 00000000..a83956ee --- /dev/null +++ b/apps/web/src/components/tools/xml-to-csv-settings.tsx @@ -0,0 +1,52 @@ +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function XmlToCsvSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["xml-to-csv"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("xml-to-csv"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = {}; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+ {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/yaml-json-settings.tsx b/apps/web/src/components/tools/yaml-json-settings.tsx new file mode 100644 index 00000000..acd73598 --- /dev/null +++ b/apps/web/src/components/tools/yaml-json-settings.tsx @@ -0,0 +1,52 @@ +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function YamlJsonSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["yaml-json"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("yaml-json"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = {}; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+ {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/lib/icon-map.ts b/apps/web/src/lib/icon-map.ts index 8493d7dd..32ad8f06 100644 --- a/apps/web/src/lib/icon-map.ts +++ b/apps/web/src/lib/icon-map.ts @@ -7,6 +7,7 @@ import { AudioLines, AudioWaveform, BellRing, + Book, BookImage, BookOpen, Braces, @@ -34,10 +35,13 @@ import { FilePen, FileText, FileType, + FileType2, FileVideo, FileX, Film, Focus, + FolderArchive, + FolderOpen, Frame, GalleryHorizontalEnd, GalleryThumbnails, @@ -65,6 +69,7 @@ import { PenLine, PenTool, Pipette, + Presentation, QrCode, RectangleHorizontal, Rewind, @@ -108,6 +113,7 @@ export const ICON_MAP: Record = { AudioLines, AudioWaveform, BellRing, + Book, BookImage, BookOpen, Braces, @@ -137,8 +143,11 @@ export const ICON_MAP: Record = { FileX, FileText, FileType, + FileType2, Film, Focus, + FolderArchive, + FolderOpen, Frame, GalleryHorizontalEnd, GalleryThumbnails, @@ -165,6 +174,7 @@ export const ICON_MAP: Record = { PenLine, PenTool, Pipette, + Presentation, QrCode, RectangleHorizontal, Rewind, diff --git a/apps/web/src/lib/tool-display-modes.ts b/apps/web/src/lib/tool-display-modes.ts index 235670c2..97c76be9 100644 --- a/apps/web/src/lib/tool-display-modes.ts +++ b/apps/web/src/lib/tool-display-modes.ts @@ -140,6 +140,10 @@ export const TOOL_DISPLAY_MODES: Record = { "waveform-image": "no-comparison", // PDF & Document tools + "convert-document": "no-comparison", + "convert-presentation": "no-comparison", + "convert-spreadsheet": "no-comparison", + "excel-to-pdf": "document", "merge-pdf": "document", "split-pdf": "no-comparison", "compress-pdf": "document", @@ -164,14 +168,24 @@ export const TOOL_DISPLAY_MODES: Record = { "pdf-to-text": "no-comparison", "pdf-to-word": "no-comparison", "pdf-metadata": "no-comparison", + "powerpoint-to-pdf": "document", "html-to-pdf": "document", + "markdown-to-docx": "no-comparison", + "markdown-to-html": "no-comparison", "markdown-to-pdf": "document", + "epub-convert": "no-comparison", + "to-epub": "no-comparison", // Data tools "csv-excel": "no-comparison", "csv-json": "no-comparison", "json-xml": "no-comparison", "split-csv": "no-comparison", + "merge-csvs": "no-comparison", + "yaml-json": "no-comparison", + "xml-to-csv": "no-comparison", + "create-zip": "no-comparison", + "extract-zip": "no-comparison", }; /** @@ -179,7 +193,9 @@ export const TOOL_DISPLAY_MODES: Record = { * Consumed by use-tool-processor; backend routes declare maxInputs. */ export const MULTI_FILE_TOOLS: ReadonlySet = new Set([ + "create-zip", "merge-audio", + "merge-csvs", "merge-pdf", "merge-videos", "replace-audio", diff --git a/apps/web/src/lib/tool-registry.tsx b/apps/web/src/lib/tool-registry.tsx index ccddccd6..58b2ca9b 100644 --- a/apps/web/src/lib/tool-registry.tsx +++ b/apps/web/src/lib/tool-registry.tsx @@ -714,6 +714,76 @@ const MarkdownToPdfSettings = lazy(() => default: m.MarkdownToPdfSettings, })), ); +const ExcelToPdfSettings = lazy(() => + import("@/components/tools/excel-to-pdf-settings").then((m) => ({ + default: m.ExcelToPdfSettings, + })), +); +const PowerpointToPdfSettings = lazy(() => + import("@/components/tools/powerpoint-to-pdf-settings").then((m) => ({ + default: m.PowerpointToPdfSettings, + })), +); +const ConvertDocumentSettings = lazy(() => + import("@/components/tools/convert-document-settings").then((m) => ({ + default: m.ConvertDocumentSettings, + })), +); +const ConvertSpreadsheetSettings = lazy(() => + import("@/components/tools/convert-spreadsheet-settings").then((m) => ({ + default: m.ConvertSpreadsheetSettings, + })), +); +const ConvertPresentationSettings = lazy(() => + import("@/components/tools/convert-presentation-settings").then((m) => ({ + default: m.ConvertPresentationSettings, + })), +); +const MarkdownToHtmlSettings = lazy(() => + import("@/components/tools/markdown-to-html-settings").then((m) => ({ + default: m.MarkdownToHtmlSettings, + })), +); +const MarkdownToDocxSettings = lazy(() => + import("@/components/tools/markdown-to-docx-settings").then((m) => ({ + default: m.MarkdownToDocxSettings, + })), +); +const EpubConvertSettings = lazy(() => + import("@/components/tools/epub-convert-settings").then((m) => ({ + default: m.EpubConvertSettings, + })), +); +const ToEpubSettings = lazy(() => + import("@/components/tools/to-epub-settings").then((m) => ({ + default: m.ToEpubSettings, + })), +); +const MergeCsvsSettings = lazy(() => + import("@/components/tools/merge-csvs-settings").then((m) => ({ + default: m.MergeCsvsSettings, + })), +); +const YamlJsonSettings = lazy(() => + import("@/components/tools/yaml-json-settings").then((m) => ({ + default: m.YamlJsonSettings, + })), +); +const XmlToCsvSettings = lazy(() => + import("@/components/tools/xml-to-csv-settings").then((m) => ({ + default: m.XmlToCsvSettings, + })), +); +const CreateZipSettings = lazy(() => + import("@/components/tools/create-zip-settings").then((m) => ({ + default: m.CreateZipSettings, + })), +); +const ExtractZipSettings = lazy(() => + import("@/components/tools/extract-zip-settings").then((m) => ({ + default: m.ExtractZipSettings, + })), +); // ── Color tool wrapper ───────────────────────────────────────────── // Color tools share a single component but differ by toolId. @@ -901,6 +971,15 @@ const ENTRY_CONFIG: ReadonlyArray<[string, RegistryEntryConfig]> = [ ["compress-pdf", { accept: ".pdf", Settings: CompressPdfSettings }], ["rotate-pdf", { accept: ".pdf", Settings: RotatePdfSettings }], ["word-to-pdf", { accept: ".docx,.doc,.odt,.rtf,.txt", Settings: WordToPdfSettings }], + ["excel-to-pdf", { accept: ".xlsx,.xls,.ods,.csv", Settings: ExcelToPdfSettings }], + ["powerpoint-to-pdf", { accept: ".pptx,.ppt,.odp", Settings: PowerpointToPdfSettings }], + ["convert-document", { accept: ".docx,.doc,.odt,.rtf,.txt", Settings: ConvertDocumentSettings }], + ["convert-spreadsheet", { accept: ".xlsx,.xls,.ods,.csv", Settings: ConvertSpreadsheetSettings }], + ["convert-presentation", { accept: ".pptx,.ppt,.odp", Settings: ConvertPresentationSettings }], + ["markdown-to-html", { accept: ".md,.markdown", Settings: MarkdownToHtmlSettings }], + ["markdown-to-docx", { accept: ".md,.markdown", Settings: MarkdownToDocxSettings }], + ["epub-convert", { accept: ".epub", Settings: EpubConvertSettings }], + ["to-epub", { accept: ".docx,.md,.html,.txt", Settings: ToEpubSettings }], // PDF depth tools (organize, secure, pdfcpu, layout) ["extract-pages", { accept: ".pdf", Settings: ExtractPagesSettings }], @@ -928,10 +1007,15 @@ const ENTRY_CONFIG: ReadonlyArray<[string, RegistryEntryConfig]> = [ ["markdown-to-pdf", { accept: ".md,.markdown", Settings: MarkdownToPdfSettings }], // Data tools - ["csv-excel", { accept: ".csv,.xlsx", Settings: CsvExcelSettings }], - ["csv-json", { accept: ".csv,.json", Settings: CsvJsonSettings }], + ["csv-excel", { accept: ".csv,.tsv,.xlsx", Settings: CsvExcelSettings }], + ["csv-json", { accept: ".csv,.tsv,.json", Settings: CsvJsonSettings }], ["json-xml", { accept: ".json,.xml", Settings: JsonXmlSettings }], - ["split-csv", { accept: ".csv", Settings: SplitCsvSettings }], + ["split-csv", { accept: ".csv,.tsv", Settings: SplitCsvSettings }], + ["merge-csvs", { accept: ".csv,.tsv", Settings: MergeCsvsSettings }], + ["yaml-json", { accept: ".yaml,.yml,.json", Settings: YamlJsonSettings }], + ["xml-to-csv", { accept: ".xml", Settings: XmlToCsvSettings }], + ["create-zip", { Settings: CreateZipSettings }], + ["extract-zip", { accept: ".zip", Settings: ExtractZipSettings }], ]; export const toolRegistry = new Map( diff --git a/docker/Dockerfile b/docker/Dockerfile index 0b8ee45e..27aefd65 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -212,12 +212,13 @@ RUN for i in 1 2 3; do apt-get -o Acquire::Retries=3 update && break || sleep $( python3 python3-pip python3-venv python3-dev \ tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu tesseract-ocr-fra tesseract-ocr-spa \ tesseract-ocr-chi-sim tesseract-ocr-jpn tesseract-ocr-kor \ - # Document engine: qpdf + LibreOffice headless + WeasyPrint runtime deps - # calibre deferred (ebook wave) + # Document engine: qpdf + LibreOffice headless + pandoc + WeasyPrint runtime deps + # calibre deferred (5.2 GB ruling; pandoc covers epub/markdown families) + pandoc \ qpdf \ libpango-1.0-0 libpangocairo-1.0-0 libcairo2 libgdk-pixbuf-2.0-0 \ fonts-dejavu-core \ - libreoffice-writer libreoffice-calc \ + libreoffice-calc libreoffice-impress libreoffice-writer \ gcc g++ \ libgl1 libglib2.0-0 libgles2 \ libegl1 libwayland-egl1 libwayland-client0 libwayland-cursor0 \ diff --git a/packages/ai/python/doc_html_pdf.py b/packages/ai/python/doc_html_pdf.py index 5119b6b3..2ce26c3b 100644 --- a/packages/ai/python/doc_html_pdf.py +++ b/packages/ai/python/doc_html_pdf.py @@ -10,11 +10,11 @@ import re import sys _REMOTE_REF_RE = re.compile( - r'(?:src|href|action)\s*=\s*["\']?\s*(https?://[^\s"\'>\)]{1,200})', + r'(?:src|href|action)\s*=\s*["\']?\s*(https?:/{1,2}[^\s"\'>\)]{1,200})', re.IGNORECASE, ) _REMOTE_CSS_URL_RE = re.compile( - r'url\s*\(\s*["\']?\s*(https?://[^\s"\'>\)]{1,200})', + r'url\s*\(\s*["\']?\s*(https?:/{1,2}[^\s"\'>\)]{1,200})', re.IGNORECASE, ) diff --git a/packages/doc-engine/src/index.ts b/packages/doc-engine/src/index.ts index b502f262..22dd4642 100644 --- a/packages/doc-engine/src/index.ts +++ b/packages/doc-engine/src/index.ts @@ -14,7 +14,8 @@ export { gsPdfaConvert, type PdfCompressionPreset, } from "./ghostscript.js"; -export { type ConvertOptions, convertDocument } from "./libreoffice.js"; +export { type ConvertOptions, convertDocument, parseConvertTarget } from "./libreoffice.js"; +export { type PandocOptions, pandocAvailable, runPandoc } from "./pandoc.js"; export { assertValidRange, qpdfDecrypt, diff --git a/packages/doc-engine/src/libreoffice.ts b/packages/doc-engine/src/libreoffice.ts index 7ef23977..3fb3eddc 100644 --- a/packages/doc-engine/src/libreoffice.ts +++ b/packages/doc-engine/src/libreoffice.ts @@ -10,23 +10,42 @@ export interface ConvertOptions { timeoutMs?: number; // default 120s, spec 4.7 hard kill } +/** + * Split a target string into the output file extension and the full + * --convert-to argument. Bare extensions ("pdf") pass through unchanged. + * Qualified targets ("docx:MS Word 2007 XML") split on the FIRST colon: + * the part before it is the output extension, the full string is the + * --convert-to value. + */ +export function parseConvertTarget(target: string): { ext: string; convertTo: string } { + const idx = target.indexOf(":"); + if (idx === -1) return { ext: target, convertTo: target }; + return { ext: target.slice(0, idx), convertTo: target }; +} + /** * LibreOffice headless conversion with per-invocation profile isolation * (spec 4.7): each run gets its own UserInstallation dir so concurrent * conversions cannot corrupt a shared profile; the profile is removed in * finally and the process is SIGKILLed at the deadline. + * + * target may be a bare extension ("pdf") or "ext:Filter Name" + * ("docx:MS Word 2007 XML"). The extension controls the output filename; + * the full string is passed to --convert-to. + * * Returns the produced file path inside outDir. */ export async function convertDocument( inputPath: string, outDir: string, - targetExt: string, + target: string, opts: ConvertOptions = {}, ): Promise { const bin = resolveSoffice(); if (!bin) throw new Error("soffice binary not found (set SOFFICE_PATH or install LibreOffice)"); const timeoutMs = opts.timeoutMs ?? 120_000; const profileDir = join(tmpdir(), `snapotter-lo-${randomUUID()}`); + const { ext, convertTo } = parseConvertTarget(target); try { await new Promise((resolvePromise, reject) => { const child = spawn( @@ -38,7 +57,7 @@ export async function convertDocument( "--nolockcheck", "--nodefault", "--convert-to", - targetExt, + convertTo, "--outdir", outDir, inputPath, @@ -70,10 +89,10 @@ export async function convertDocument( else reject(new Error(`LibreOffice exited ${code ?? signal}: ${err.slice(-1000)}`)); }); }); - const expected = `${basename(inputPath, extname(inputPath))}.${targetExt}`; + const expected = `${basename(inputPath, extname(inputPath))}.${ext}`; const produced = (await readdir(outDir)).find((f) => f === expected); if (!produced) - throw new Error(`LibreOffice produced no ${targetExt} output for ${basename(inputPath)}`); + throw new Error(`LibreOffice produced no ${ext} output for ${basename(inputPath)}`); return join(outDir, produced); } finally { await rm(profileDir, { recursive: true, force: true }).catch(() => {}); diff --git a/packages/doc-engine/src/pandoc.ts b/packages/doc-engine/src/pandoc.ts new file mode 100644 index 00000000..913d096d --- /dev/null +++ b/packages/doc-engine/src/pandoc.ts @@ -0,0 +1,84 @@ +import { spawn, spawnSync } from "node:child_process"; + +let cachedAvailable: boolean | null = null; + +/** True when a pandoc binary is on PATH (gates local tests; the image installs it). */ +export function pandocAvailable(): boolean { + if (cachedAvailable !== null) return cachedAvailable; + const r = spawnSync("pandoc", ["--version"], { stdio: "ignore" }); + cachedAvailable = r.status === 0; + return cachedAvailable; +} + +/** Detect pandoc major version once (2.x vs 3.x) to choose the self-contained flag. */ +let cachedSelfContainedArgs: string[] | null = null; + +function selfContainedArgs(): string[] { + if (cachedSelfContainedArgs !== null) return cachedSelfContainedArgs; + const r = spawnSync("pandoc", ["--version"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + if (r.status !== 0 || !r.stdout) { + // Fallback to 2.x flag if we cannot determine the version. + cachedSelfContainedArgs = ["--self-contained"]; + return cachedSelfContainedArgs; + } + // First line: "pandoc 2.17.1.1" or "pandoc 3.1.2" + const match = r.stdout.match(/pandoc\s+(\d+)/); + const major = match ? Number(match[1]) : 2; + cachedSelfContainedArgs = + major >= 3 ? ["--embed-resources", "--standalone"] : ["--self-contained"]; + return cachedSelfContainedArgs; +} + +export interface PandocOptions { + timeoutMs?: number; + /** Inline images/css as data: URIs (pandoc 2.x: --self-contained; 3.x: --embed-resources). */ + selfContained?: boolean; + /** Extra args appended verbatim (e.g. ["--standalone"]). */ + extraArgs?: string[]; +} + +/** Runs pandoc in -> out; rejects with the stderr tail on failure. */ +export function runPandoc( + inPath: string, + outPath: string, + opts: PandocOptions = {}, +): Promise { + const timeoutMs = opts.timeoutMs ?? 120_000; + const args = [inPath, "-o", outPath]; + if (opts.selfContained) { + args.push(...selfContainedArgs()); + } + if (opts.extraArgs) { + args.push(...opts.extraArgs); + } + return new Promise((resolvePromise, reject) => { + const child = spawn("pandoc", args, { stdio: ["ignore", "pipe", "pipe"] }); + let err = ""; + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill("SIGKILL"); + reject(new Error(`pandoc timed out after ${Math.round(timeoutMs / 1000)}s`)); + }, timeoutMs); + child.stderr.on("data", (c: Buffer) => { + err = (err + c.toString("utf8")).slice(-4096); + }); + child.on("error", (e) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(e); + }); + child.on("close", (code, signal) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (code === 0) resolvePromise(); + else reject(new Error(`pandoc exited ${code ?? signal}: ${err.slice(-1000)}`)); + }); + }); +} diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index b9265d14..41fbd30b 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1141,6 +1141,51 @@ export const TOOLS: Tool[] = [ acceptedInputs: [".pdf"], executionHint: "fast", }, + { + id: "convert-document", + name: "Convert Document", + description: "Convert between Word, OpenDocument, RTF, and plain text formats", + category: "documents", + icon: "FileType2", + route: "/convert-document", + modality: "document", + acceptedInputs: [".docx", ".doc", ".odt", ".rtf", ".txt"], + executionHint: "long", + }, + { + id: "convert-presentation", + name: "Convert Presentation", + description: "Convert between PowerPoint and OpenDocument presentation formats", + category: "documents", + icon: "Presentation", + route: "/convert-presentation", + modality: "document", + acceptedInputs: [".pptx", ".ppt", ".odp"], + executionHint: "long", + }, + { + id: "convert-spreadsheet", + name: "Convert Spreadsheet", + description: + "Convert between Excel, OpenDocument, and CSV formats. Multi-sheet workbooks export the first sheet to CSV.", + category: "documents", + icon: "Grid3x3", + route: "/convert-spreadsheet", + modality: "document", + acceptedInputs: [".xlsx", ".xls", ".ods", ".csv"], + executionHint: "long", + }, + { + id: "excel-to-pdf", + name: "Excel to PDF", + description: "Convert spreadsheets to PDF. Wide sheets may paginate across multiple pages.", + category: "documents", + icon: "Table", + route: "/excel-to-pdf", + modality: "document", + acceptedInputs: [".xlsx", ".xls", ".ods", ".csv"], + executionHint: "long", + }, { id: "word-to-pdf", name: "Word to PDF", @@ -1361,6 +1406,17 @@ export const TOOLS: Tool[] = [ acceptedInputs: [".pdf"], executionHint: "fast", }, + { + id: "powerpoint-to-pdf", + name: "PowerPoint to PDF", + description: "Convert presentations to PDF", + category: "documents", + icon: "Presentation", + route: "/powerpoint-to-pdf", + modality: "document", + acceptedInputs: [".pptx", ".ppt", ".odp"], + executionHint: "long", + }, { id: "html-to-pdf", name: "HTML to PDF", @@ -1372,6 +1428,29 @@ export const TOOLS: Tool[] = [ acceptedInputs: [".html", ".htm"], executionHint: "long", }, + { + id: "markdown-to-docx", + name: "Markdown to Word", + description: "Convert a Markdown file to a Word document (DOCX)", + category: "documents", + icon: "FileType", + route: "/markdown-to-docx", + modality: "document", + acceptedInputs: [".md", ".markdown"], + executionHint: "fast", + }, + { + id: "markdown-to-html", + name: "Markdown to HTML", + description: + "Convert a Markdown file to a standalone HTML page. Remote images in the source are left as-is in the output.", + category: "documents", + icon: "FileCode", + route: "/markdown-to-html", + modality: "document", + acceptedInputs: [".md", ".markdown"], + executionHint: "fast", + }, { id: "markdown-to-pdf", name: "Markdown to PDF", @@ -1384,6 +1463,29 @@ export const TOOLS: Tool[] = [ acceptedInputs: [".md", ".markdown"], executionHint: "long", }, + { + id: "epub-convert", + name: "Convert EPUB", + description: + "Convert an EPUB to PDF, DOCX, HTML, or Markdown. Remote resources inside the book are not fetched.", + category: "documents", + icon: "BookOpen", + route: "/epub-convert", + modality: "document", + acceptedInputs: [".epub"], + executionHint: "long", + }, + { + id: "to-epub", + name: "Convert to EPUB", + description: "Convert Word, Markdown, HTML, or plain text files to EPUB", + category: "documents", + icon: "Book", + route: "/to-epub", + modality: "document", + acceptedInputs: [".docx", ".md", ".html", ".txt"], + executionHint: "long", + }, // Data Files { id: "csv-excel", @@ -1393,7 +1495,7 @@ export const TOOLS: Tool[] = [ icon: "Table", route: "/csv-excel", modality: "file", - acceptedInputs: [".csv", ".xlsx"], + acceptedInputs: [".csv", ".tsv", ".xlsx"], executionHint: "fast", }, { @@ -1404,7 +1506,7 @@ export const TOOLS: Tool[] = [ icon: "Braces", route: "/csv-json", modality: "file", - acceptedInputs: [".csv", ".json"], + acceptedInputs: [".csv", ".tsv", ".json"], executionHint: "fast", }, { @@ -1426,7 +1528,64 @@ export const TOOLS: Tool[] = [ icon: "Split", route: "/split-csv", modality: "file", - acceptedInputs: [".csv"], + acceptedInputs: [".csv", ".tsv"], + executionHint: "fast", + }, + { + id: "merge-csvs", + name: "Merge CSVs", + description: "Combine multiple CSV or TSV files with matching columns into one", + category: "data", + icon: "Combine", + route: "/merge-csvs", + modality: "file", + acceptedInputs: [".csv", ".tsv"], + executionHint: "fast", + }, + { + id: "yaml-json", + name: "YAML / JSON", + description: "Convert between YAML and JSON, both directions", + category: "data", + icon: "Braces", + route: "/yaml-json", + modality: "file", + acceptedInputs: [".yaml", ".yml", ".json"], + executionHint: "fast", + }, + { + id: "xml-to-csv", + name: "XML to CSV", + description: "Extract repeating elements from XML into a CSV table", + category: "data", + icon: "Grid3x3", + route: "/xml-to-csv", + modality: "file", + acceptedInputs: [".xml"], + executionHint: "fast", + }, + // Archives + { + id: "create-zip", + name: "Create ZIP", + description: "Bundle multiple files into a single ZIP archive", + category: "data", + icon: "FolderArchive", + route: "/create-zip", + modality: "file", + acceptedInputs: [], + executionHint: "fast", + }, + { + id: "extract-zip", + name: "Extract ZIP", + description: + "Safely extract files from a ZIP archive with bomb protection. Single-file archives return the file directly.", + category: "data", + icon: "FolderOpen", + route: "/extract-zip", + modality: "file", + acceptedInputs: [".zip"], executionHint: "fast", }, ]; diff --git a/packages/shared/src/i18n/ar.ts b/packages/shared/src/i18n/ar.ts index 6a62de40..c890f5b4 100644 --- a/packages/shared/src/i18n/ar.ts +++ b/packages/shared/src/i18n/ar.ts @@ -468,6 +468,23 @@ export const ar: TranslationKeys = { name: "Rotate PDF", description: "Rotate pages in a PDF", }, + "convert-document": { + name: "Convert Document", + description: "Convert between Word, OpenDocument, RTF, and plain text formats", + }, + "convert-presentation": { + name: "Convert Presentation", + description: "Convert between PowerPoint and OpenDocument presentation formats", + }, + "convert-spreadsheet": { + name: "Convert Spreadsheet", + description: + "Convert between Excel, OpenDocument, and CSV formats. Multi-sheet workbooks export the first sheet to CSV.", + }, + "excel-to-pdf": { + name: "Excel to PDF", + description: "Convert spreadsheets to PDF. Wide sheets may paginate across multiple pages.", + }, "word-to-pdf": { name: "Word to PDF", description: "Convert Word documents to PDF", @@ -548,15 +565,37 @@ export const ar: TranslationKeys = { name: "PDF Metadata", description: "Read and write PDF document metadata", }, + "powerpoint-to-pdf": { + name: "PowerPoint to PDF", + description: "Convert presentations to PDF", + }, "html-to-pdf": { name: "HTML to PDF", description: "Convert an HTML file to PDF. Remote resources are disabled for privacy.", }, + "markdown-to-docx": { + name: "Markdown to Word", + description: "Convert a Markdown file to a Word document (DOCX)", + }, + "markdown-to-html": { + name: "Markdown to HTML", + description: + "Convert a Markdown file to a standalone HTML page. Remote images in the source are left as-is in the output.", + }, "markdown-to-pdf": { name: "Markdown to PDF", description: "Convert a Markdown file to a styled PDF. Remote resources are disabled for privacy.", }, + "epub-convert": { + name: "Convert EPUB", + description: + "Convert an EPUB to PDF, DOCX, HTML, or Markdown. Remote resources inside the book are not fetched.", + }, + "to-epub": { + name: "Convert to EPUB", + description: "Convert Word, Markdown, HTML, or plain text files to EPUB", + }, "csv-excel": { name: "CSV to Excel", description: "Convert between CSV and Excel (XLSX), both directions", @@ -573,6 +612,27 @@ export const ar: TranslationKeys = { name: "Split CSV", description: "Split a CSV into smaller files by row count", }, + "merge-csvs": { + name: "Merge CSVs", + description: "Combine multiple CSV or TSV files with matching columns into one", + }, + "yaml-json": { + name: "YAML / JSON", + description: "Convert between YAML and JSON, both directions", + }, + "xml-to-csv": { + name: "XML to CSV", + description: "Extract repeating elements from XML into a CSV table", + }, + "create-zip": { + name: "Create ZIP", + description: "Bundle multiple files into a single ZIP archive", + }, + "extract-zip": { + name: "Extract ZIP", + description: + "Safely extract files from a ZIP archive with bomb protection. Single-file archives return the file directly.", + }, pipeline: { name: "منشئ Pipeline", description: "ربط عدة أدوات في سير عمل واحد", @@ -1909,6 +1969,35 @@ export const ar: TranslationKeys = { submitBatch: "Rotate ({count} files)", progressLabel: "Rotating", }, + "convert-document": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-presentation": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-spreadsheet": { + format: "Output format", + hint: "Multi-sheet workbooks export the first sheet to CSV.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "excel-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "powerpoint-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "word-to-pdf": { submit: "Convert to PDF", submitBatch: "Convert ({count} files)", @@ -2043,12 +2132,35 @@ export const ar: TranslationKeys = { submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "markdown-to-docx": { + submit: "Convert to DOCX", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "markdown-to-html": { + hint: "Remote images in the source are left as-is in the output.", + submit: "Convert to HTML", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "markdown-to-pdf": { hint: "Remote resources are disabled for privacy.", submit: "Convert to PDF", submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "epub-convert": { + format: "Output format", + hint: "Remote resources inside the book are not fetched.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "to-epub": { + submit: "Convert to EPUB", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "csv-excel": { sheet: "Worksheet number", submit: "Convert", @@ -2074,6 +2186,31 @@ export const ar: TranslationKeys = { submitBatch: "Split ({count} files)", progressLabel: "Splitting", }, + "merge-csvs": { + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "yaml-json": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "xml-to-csv": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "create-zip": { + submit: "Create ZIP", + submitBatch: "Create ZIP ({count} files)", + progressLabel: "Zipping", + }, + "extract-zip": { + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, }, urlImport: { title: "استيراد من روابط", diff --git a/packages/shared/src/i18n/de.ts b/packages/shared/src/i18n/de.ts index d5fd95f5..a75c822f 100644 --- a/packages/shared/src/i18n/de.ts +++ b/packages/shared/src/i18n/de.ts @@ -473,6 +473,23 @@ export const de: TranslationKeys = { name: "Rotate PDF", description: "Rotate pages in a PDF", }, + "convert-document": { + name: "Convert Document", + description: "Convert between Word, OpenDocument, RTF, and plain text formats", + }, + "convert-presentation": { + name: "Convert Presentation", + description: "Convert between PowerPoint and OpenDocument presentation formats", + }, + "convert-spreadsheet": { + name: "Convert Spreadsheet", + description: + "Convert between Excel, OpenDocument, and CSV formats. Multi-sheet workbooks export the first sheet to CSV.", + }, + "excel-to-pdf": { + name: "Excel to PDF", + description: "Convert spreadsheets to PDF. Wide sheets may paginate across multiple pages.", + }, "word-to-pdf": { name: "Word to PDF", description: "Convert Word documents to PDF", @@ -553,15 +570,37 @@ export const de: TranslationKeys = { name: "PDF Metadata", description: "Read and write PDF document metadata", }, + "powerpoint-to-pdf": { + name: "PowerPoint to PDF", + description: "Convert presentations to PDF", + }, "html-to-pdf": { name: "HTML to PDF", description: "Convert an HTML file to PDF. Remote resources are disabled for privacy.", }, + "markdown-to-docx": { + name: "Markdown to Word", + description: "Convert a Markdown file to a Word document (DOCX)", + }, + "markdown-to-html": { + name: "Markdown to HTML", + description: + "Convert a Markdown file to a standalone HTML page. Remote images in the source are left as-is in the output.", + }, "markdown-to-pdf": { name: "Markdown to PDF", description: "Convert a Markdown file to a styled PDF. Remote resources are disabled for privacy.", }, + "epub-convert": { + name: "Convert EPUB", + description: + "Convert an EPUB to PDF, DOCX, HTML, or Markdown. Remote resources inside the book are not fetched.", + }, + "to-epub": { + name: "Convert to EPUB", + description: "Convert Word, Markdown, HTML, or plain text files to EPUB", + }, "csv-excel": { name: "CSV to Excel", description: "Convert between CSV and Excel (XLSX), both directions", @@ -578,6 +617,27 @@ export const de: TranslationKeys = { name: "Split CSV", description: "Split a CSV into smaller files by row count", }, + "merge-csvs": { + name: "Merge CSVs", + description: "Combine multiple CSV or TSV files with matching columns into one", + }, + "yaml-json": { + name: "YAML / JSON", + description: "Convert between YAML and JSON, both directions", + }, + "xml-to-csv": { + name: "XML to CSV", + description: "Extract repeating elements from XML into a CSV table", + }, + "create-zip": { + name: "Create ZIP", + description: "Bundle multiple files into a single ZIP archive", + }, + "extract-zip": { + name: "Extract ZIP", + description: + "Safely extract files from a ZIP archive with bomb protection. Single-file archives return the file directly.", + }, pipeline: { name: "Pipeline-Builder", description: "Mehrere Werkzeuge zu einem Workflow verketten", @@ -1922,6 +1982,35 @@ export const de: TranslationKeys = { submitBatch: "Rotate ({count} files)", progressLabel: "Rotating", }, + "convert-document": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-presentation": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-spreadsheet": { + format: "Output format", + hint: "Multi-sheet workbooks export the first sheet to CSV.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "excel-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "powerpoint-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "word-to-pdf": { submit: "Convert to PDF", submitBatch: "Convert ({count} files)", @@ -2056,12 +2145,35 @@ export const de: TranslationKeys = { submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "markdown-to-docx": { + submit: "Convert to DOCX", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "markdown-to-html": { + hint: "Remote images in the source are left as-is in the output.", + submit: "Convert to HTML", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "markdown-to-pdf": { hint: "Remote resources are disabled for privacy.", submit: "Convert to PDF", submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "epub-convert": { + format: "Output format", + hint: "Remote resources inside the book are not fetched.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "to-epub": { + submit: "Convert to EPUB", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "csv-excel": { sheet: "Worksheet number", submit: "Convert", @@ -2087,6 +2199,31 @@ export const de: TranslationKeys = { submitBatch: "Split ({count} files)", progressLabel: "Splitting", }, + "merge-csvs": { + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "yaml-json": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "xml-to-csv": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "create-zip": { + submit: "Create ZIP", + submitBatch: "Create ZIP ({count} files)", + progressLabel: "Zipping", + }, + "extract-zip": { + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, }, urlImport: { title: "Von URLs importieren", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index 2566ceea..dbab0c8b 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -428,6 +428,23 @@ export const en = { name: "Rotate PDF", description: "Rotate pages in a PDF", }, + "convert-document": { + name: "Convert Document", + description: "Convert between Word, OpenDocument, RTF, and plain text formats", + }, + "convert-presentation": { + name: "Convert Presentation", + description: "Convert between PowerPoint and OpenDocument presentation formats", + }, + "convert-spreadsheet": { + name: "Convert Spreadsheet", + description: + "Convert between Excel, OpenDocument, and CSV formats. Multi-sheet workbooks export the first sheet to CSV.", + }, + "excel-to-pdf": { + name: "Excel to PDF", + description: "Convert spreadsheets to PDF. Wide sheets may paginate across multiple pages.", + }, "word-to-pdf": { name: "Word to PDF", description: "Convert Word documents to PDF", @@ -508,15 +525,37 @@ export const en = { name: "PDF Metadata", description: "Read and write PDF document metadata", }, + "powerpoint-to-pdf": { + name: "PowerPoint to PDF", + description: "Convert presentations to PDF", + }, "html-to-pdf": { name: "HTML to PDF", description: "Convert an HTML file to PDF. Remote resources are disabled for privacy.", }, + "markdown-to-docx": { + name: "Markdown to Word", + description: "Convert a Markdown file to a Word document (DOCX)", + }, + "markdown-to-html": { + name: "Markdown to HTML", + description: + "Convert a Markdown file to a standalone HTML page. Remote images in the source are left as-is in the output.", + }, "markdown-to-pdf": { name: "Markdown to PDF", description: "Convert a Markdown file to a styled PDF. Remote resources are disabled for privacy.", }, + "epub-convert": { + name: "Convert EPUB", + description: + "Convert an EPUB to PDF, DOCX, HTML, or Markdown. Remote resources inside the book are not fetched.", + }, + "to-epub": { + name: "Convert to EPUB", + description: "Convert Word, Markdown, HTML, or plain text files to EPUB", + }, "csv-excel": { name: "CSV to Excel", description: "Convert between CSV and Excel (XLSX), both directions", @@ -533,6 +572,27 @@ export const en = { name: "Split CSV", description: "Split a CSV into smaller files by row count", }, + "merge-csvs": { + name: "Merge CSVs", + description: "Combine multiple CSV or TSV files with matching columns into one", + }, + "yaml-json": { + name: "YAML / JSON", + description: "Convert between YAML and JSON, both directions", + }, + "xml-to-csv": { + name: "XML to CSV", + description: "Extract repeating elements from XML into a CSV table", + }, + "create-zip": { + name: "Create ZIP", + description: "Bundle multiple files into a single ZIP archive", + }, + "extract-zip": { + name: "Extract ZIP", + description: + "Safely extract files from a ZIP archive with bomb protection. Single-file archives return the file directly.", + }, pipeline: { name: "Pipeline Builder", description: "Chain multiple tools into a workflow" }, processing: { canceled: "Processing canceled" }, mediaPlayer: { @@ -1867,6 +1927,35 @@ export const en = { submitBatch: "Rotate ({count} files)", progressLabel: "Rotating", }, + "convert-document": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-presentation": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-spreadsheet": { + format: "Output format", + hint: "Multi-sheet workbooks export the first sheet to CSV.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "excel-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "powerpoint-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "word-to-pdf": { submit: "Convert to PDF", submitBatch: "Convert ({count} files)", @@ -2001,12 +2090,35 @@ export const en = { submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "markdown-to-docx": { + submit: "Convert to DOCX", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "markdown-to-html": { + hint: "Remote images in the source are left as-is in the output.", + submit: "Convert to HTML", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "markdown-to-pdf": { hint: "Remote resources are disabled for privacy.", submit: "Convert to PDF", submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "epub-convert": { + format: "Output format", + hint: "Remote resources inside the book are not fetched.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "to-epub": { + submit: "Convert to EPUB", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "csv-excel": { sheet: "Worksheet number", submit: "Convert", @@ -2032,6 +2144,31 @@ export const en = { submitBatch: "Split ({count} files)", progressLabel: "Splitting", }, + "merge-csvs": { + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "yaml-json": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "xml-to-csv": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "create-zip": { + submit: "Create ZIP", + submitBatch: "Create ZIP ({count} files)", + progressLabel: "Zipping", + }, + "extract-zip": { + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, }, urlImport: { title: "Import from URLs", diff --git a/packages/shared/src/i18n/es.ts b/packages/shared/src/i18n/es.ts index 78bbf8ba..2de63b01 100644 --- a/packages/shared/src/i18n/es.ts +++ b/packages/shared/src/i18n/es.ts @@ -458,6 +458,23 @@ export const es: TranslationKeys = { name: "Rotate PDF", description: "Rotate pages in a PDF", }, + "convert-document": { + name: "Convert Document", + description: "Convert between Word, OpenDocument, RTF, and plain text formats", + }, + "convert-presentation": { + name: "Convert Presentation", + description: "Convert between PowerPoint and OpenDocument presentation formats", + }, + "convert-spreadsheet": { + name: "Convert Spreadsheet", + description: + "Convert between Excel, OpenDocument, and CSV formats. Multi-sheet workbooks export the first sheet to CSV.", + }, + "excel-to-pdf": { + name: "Excel to PDF", + description: "Convert spreadsheets to PDF. Wide sheets may paginate across multiple pages.", + }, "word-to-pdf": { name: "Word to PDF", description: "Convert Word documents to PDF", @@ -538,15 +555,37 @@ export const es: TranslationKeys = { name: "PDF Metadata", description: "Read and write PDF document metadata", }, + "powerpoint-to-pdf": { + name: "PowerPoint to PDF", + description: "Convert presentations to PDF", + }, "html-to-pdf": { name: "HTML to PDF", description: "Convert an HTML file to PDF. Remote resources are disabled for privacy.", }, + "markdown-to-docx": { + name: "Markdown to Word", + description: "Convert a Markdown file to a Word document (DOCX)", + }, + "markdown-to-html": { + name: "Markdown to HTML", + description: + "Convert a Markdown file to a standalone HTML page. Remote images in the source are left as-is in the output.", + }, "markdown-to-pdf": { name: "Markdown to PDF", description: "Convert a Markdown file to a styled PDF. Remote resources are disabled for privacy.", }, + "epub-convert": { + name: "Convert EPUB", + description: + "Convert an EPUB to PDF, DOCX, HTML, or Markdown. Remote resources inside the book are not fetched.", + }, + "to-epub": { + name: "Convert to EPUB", + description: "Convert Word, Markdown, HTML, or plain text files to EPUB", + }, "csv-excel": { name: "CSV to Excel", description: "Convert between CSV and Excel (XLSX), both directions", @@ -563,6 +602,27 @@ export const es: TranslationKeys = { name: "Split CSV", description: "Split a CSV into smaller files by row count", }, + "merge-csvs": { + name: "Merge CSVs", + description: "Combine multiple CSV or TSV files with matching columns into one", + }, + "yaml-json": { + name: "YAML / JSON", + description: "Convert between YAML and JSON, both directions", + }, + "xml-to-csv": { + name: "XML to CSV", + description: "Extract repeating elements from XML into a CSV table", + }, + "create-zip": { + name: "Create ZIP", + description: "Bundle multiple files into a single ZIP archive", + }, + "extract-zip": { + name: "Extract ZIP", + description: + "Safely extract files from a ZIP archive with bomb protection. Single-file archives return the file directly.", + }, pipeline: { name: "Constructor de Pipeline", description: "Encadena multiples herramientas en un flujo de trabajo", @@ -1904,6 +1964,35 @@ export const es: TranslationKeys = { submitBatch: "Rotate ({count} files)", progressLabel: "Rotating", }, + "convert-document": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-presentation": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-spreadsheet": { + format: "Output format", + hint: "Multi-sheet workbooks export the first sheet to CSV.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "excel-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "powerpoint-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "word-to-pdf": { submit: "Convert to PDF", submitBatch: "Convert ({count} files)", @@ -2038,12 +2127,35 @@ export const es: TranslationKeys = { submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "markdown-to-docx": { + submit: "Convert to DOCX", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "markdown-to-html": { + hint: "Remote images in the source are left as-is in the output.", + submit: "Convert to HTML", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "markdown-to-pdf": { hint: "Remote resources are disabled for privacy.", submit: "Convert to PDF", submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "epub-convert": { + format: "Output format", + hint: "Remote resources inside the book are not fetched.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "to-epub": { + submit: "Convert to EPUB", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "csv-excel": { sheet: "Worksheet number", submit: "Convert", @@ -2069,6 +2181,31 @@ export const es: TranslationKeys = { submitBatch: "Split ({count} files)", progressLabel: "Splitting", }, + "merge-csvs": { + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "yaml-json": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "xml-to-csv": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "create-zip": { + submit: "Create ZIP", + submitBatch: "Create ZIP ({count} files)", + progressLabel: "Zipping", + }, + "extract-zip": { + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, }, urlImport: { title: "Importar desde URLs", diff --git a/packages/shared/src/i18n/fr.ts b/packages/shared/src/i18n/fr.ts index 19708e4c..366f5a0d 100644 --- a/packages/shared/src/i18n/fr.ts +++ b/packages/shared/src/i18n/fr.ts @@ -474,6 +474,23 @@ export const fr: TranslationKeys = { name: "Rotate PDF", description: "Rotate pages in a PDF", }, + "convert-document": { + name: "Convert Document", + description: "Convert between Word, OpenDocument, RTF, and plain text formats", + }, + "convert-presentation": { + name: "Convert Presentation", + description: "Convert between PowerPoint and OpenDocument presentation formats", + }, + "convert-spreadsheet": { + name: "Convert Spreadsheet", + description: + "Convert between Excel, OpenDocument, and CSV formats. Multi-sheet workbooks export the first sheet to CSV.", + }, + "excel-to-pdf": { + name: "Excel to PDF", + description: "Convert spreadsheets to PDF. Wide sheets may paginate across multiple pages.", + }, "word-to-pdf": { name: "Word to PDF", description: "Convert Word documents to PDF", @@ -554,15 +571,37 @@ export const fr: TranslationKeys = { name: "PDF Metadata", description: "Read and write PDF document metadata", }, + "powerpoint-to-pdf": { + name: "PowerPoint to PDF", + description: "Convert presentations to PDF", + }, "html-to-pdf": { name: "HTML to PDF", description: "Convert an HTML file to PDF. Remote resources are disabled for privacy.", }, + "markdown-to-docx": { + name: "Markdown to Word", + description: "Convert a Markdown file to a Word document (DOCX)", + }, + "markdown-to-html": { + name: "Markdown to HTML", + description: + "Convert a Markdown file to a standalone HTML page. Remote images in the source are left as-is in the output.", + }, "markdown-to-pdf": { name: "Markdown to PDF", description: "Convert a Markdown file to a styled PDF. Remote resources are disabled for privacy.", }, + "epub-convert": { + name: "Convert EPUB", + description: + "Convert an EPUB to PDF, DOCX, HTML, or Markdown. Remote resources inside the book are not fetched.", + }, + "to-epub": { + name: "Convert to EPUB", + description: "Convert Word, Markdown, HTML, or plain text files to EPUB", + }, "csv-excel": { name: "CSV to Excel", description: "Convert between CSV and Excel (XLSX), both directions", @@ -579,6 +618,27 @@ export const fr: TranslationKeys = { name: "Split CSV", description: "Split a CSV into smaller files by row count", }, + "merge-csvs": { + name: "Merge CSVs", + description: "Combine multiple CSV or TSV files with matching columns into one", + }, + "yaml-json": { + name: "YAML / JSON", + description: "Convert between YAML and JSON, both directions", + }, + "xml-to-csv": { + name: "XML to CSV", + description: "Extract repeating elements from XML into a CSV table", + }, + "create-zip": { + name: "Create ZIP", + description: "Bundle multiple files into a single ZIP archive", + }, + "extract-zip": { + name: "Extract ZIP", + description: + "Safely extract files from a ZIP archive with bomb protection. Single-file archives return the file directly.", + }, pipeline: { name: "Constructeur de Pipeline", description: "Enchainez plusieurs outils dans un flux de travail", @@ -1923,6 +1983,35 @@ export const fr: TranslationKeys = { submitBatch: "Rotate ({count} files)", progressLabel: "Rotating", }, + "convert-document": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-presentation": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-spreadsheet": { + format: "Output format", + hint: "Multi-sheet workbooks export the first sheet to CSV.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "excel-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "powerpoint-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "word-to-pdf": { submit: "Convert to PDF", submitBatch: "Convert ({count} files)", @@ -2057,12 +2146,35 @@ export const fr: TranslationKeys = { submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "markdown-to-docx": { + submit: "Convert to DOCX", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "markdown-to-html": { + hint: "Remote images in the source are left as-is in the output.", + submit: "Convert to HTML", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "markdown-to-pdf": { hint: "Remote resources are disabled for privacy.", submit: "Convert to PDF", submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "epub-convert": { + format: "Output format", + hint: "Remote resources inside the book are not fetched.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "to-epub": { + submit: "Convert to EPUB", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "csv-excel": { sheet: "Worksheet number", submit: "Convert", @@ -2088,6 +2200,31 @@ export const fr: TranslationKeys = { submitBatch: "Split ({count} files)", progressLabel: "Splitting", }, + "merge-csvs": { + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "yaml-json": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "xml-to-csv": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "create-zip": { + submit: "Create ZIP", + submitBatch: "Create ZIP ({count} files)", + progressLabel: "Zipping", + }, + "extract-zip": { + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, }, urlImport: { title: "Importer depuis des URLs", diff --git a/packages/shared/src/i18n/hi.ts b/packages/shared/src/i18n/hi.ts index b64e83ff..a5193dc0 100644 --- a/packages/shared/src/i18n/hi.ts +++ b/packages/shared/src/i18n/hi.ts @@ -465,6 +465,23 @@ export const hi: TranslationKeys = { name: "Rotate PDF", description: "Rotate pages in a PDF", }, + "convert-document": { + name: "Convert Document", + description: "Convert between Word, OpenDocument, RTF, and plain text formats", + }, + "convert-presentation": { + name: "Convert Presentation", + description: "Convert between PowerPoint and OpenDocument presentation formats", + }, + "convert-spreadsheet": { + name: "Convert Spreadsheet", + description: + "Convert between Excel, OpenDocument, and CSV formats. Multi-sheet workbooks export the first sheet to CSV.", + }, + "excel-to-pdf": { + name: "Excel to PDF", + description: "Convert spreadsheets to PDF. Wide sheets may paginate across multiple pages.", + }, "word-to-pdf": { name: "Word to PDF", description: "Convert Word documents to PDF", @@ -545,15 +562,37 @@ export const hi: TranslationKeys = { name: "PDF Metadata", description: "Read and write PDF document metadata", }, + "powerpoint-to-pdf": { + name: "PowerPoint to PDF", + description: "Convert presentations to PDF", + }, "html-to-pdf": { name: "HTML to PDF", description: "Convert an HTML file to PDF. Remote resources are disabled for privacy.", }, + "markdown-to-docx": { + name: "Markdown to Word", + description: "Convert a Markdown file to a Word document (DOCX)", + }, + "markdown-to-html": { + name: "Markdown to HTML", + description: + "Convert a Markdown file to a standalone HTML page. Remote images in the source are left as-is in the output.", + }, "markdown-to-pdf": { name: "Markdown to PDF", description: "Convert a Markdown file to a styled PDF. Remote resources are disabled for privacy.", }, + "epub-convert": { + name: "Convert EPUB", + description: + "Convert an EPUB to PDF, DOCX, HTML, or Markdown. Remote resources inside the book are not fetched.", + }, + "to-epub": { + name: "Convert to EPUB", + description: "Convert Word, Markdown, HTML, or plain text files to EPUB", + }, "csv-excel": { name: "CSV to Excel", description: "Convert between CSV and Excel (XLSX), both directions", @@ -570,6 +609,27 @@ export const hi: TranslationKeys = { name: "Split CSV", description: "Split a CSV into smaller files by row count", }, + "merge-csvs": { + name: "Merge CSVs", + description: "Combine multiple CSV or TSV files with matching columns into one", + }, + "yaml-json": { + name: "YAML / JSON", + description: "Convert between YAML and JSON, both directions", + }, + "xml-to-csv": { + name: "XML to CSV", + description: "Extract repeating elements from XML into a CSV table", + }, + "create-zip": { + name: "Create ZIP", + description: "Bundle multiple files into a single ZIP archive", + }, + "extract-zip": { + name: "Extract ZIP", + description: + "Safely extract files from a ZIP archive with bomb protection. Single-file archives return the file directly.", + }, pipeline: { name: "Pipeline बिल्डर", description: "कई टूल्स को एक वर्कफ्लो में चेन करें", @@ -1905,6 +1965,35 @@ export const hi: TranslationKeys = { submitBatch: "Rotate ({count} files)", progressLabel: "Rotating", }, + "convert-document": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-presentation": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-spreadsheet": { + format: "Output format", + hint: "Multi-sheet workbooks export the first sheet to CSV.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "excel-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "powerpoint-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "word-to-pdf": { submit: "Convert to PDF", submitBatch: "Convert ({count} files)", @@ -2039,12 +2128,35 @@ export const hi: TranslationKeys = { submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "markdown-to-docx": { + submit: "Convert to DOCX", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "markdown-to-html": { + hint: "Remote images in the source are left as-is in the output.", + submit: "Convert to HTML", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "markdown-to-pdf": { hint: "Remote resources are disabled for privacy.", submit: "Convert to PDF", submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "epub-convert": { + format: "Output format", + hint: "Remote resources inside the book are not fetched.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "to-epub": { + submit: "Convert to EPUB", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "csv-excel": { sheet: "Worksheet number", submit: "Convert", @@ -2070,6 +2182,31 @@ export const hi: TranslationKeys = { submitBatch: "Split ({count} files)", progressLabel: "Splitting", }, + "merge-csvs": { + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "yaml-json": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "xml-to-csv": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "create-zip": { + submit: "Create ZIP", + submitBatch: "Create ZIP ({count} files)", + progressLabel: "Zipping", + }, + "extract-zip": { + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, }, urlImport: { title: "URL से इम्पोर्ट करें", diff --git a/packages/shared/src/i18n/id.ts b/packages/shared/src/i18n/id.ts index c9ad66b3..2114719a 100644 --- a/packages/shared/src/i18n/id.ts +++ b/packages/shared/src/i18n/id.ts @@ -473,6 +473,23 @@ export const id: TranslationKeys = { name: "Rotate PDF", description: "Rotate pages in a PDF", }, + "convert-document": { + name: "Convert Document", + description: "Convert between Word, OpenDocument, RTF, and plain text formats", + }, + "convert-presentation": { + name: "Convert Presentation", + description: "Convert between PowerPoint and OpenDocument presentation formats", + }, + "convert-spreadsheet": { + name: "Convert Spreadsheet", + description: + "Convert between Excel, OpenDocument, and CSV formats. Multi-sheet workbooks export the first sheet to CSV.", + }, + "excel-to-pdf": { + name: "Excel to PDF", + description: "Convert spreadsheets to PDF. Wide sheets may paginate across multiple pages.", + }, "word-to-pdf": { name: "Word to PDF", description: "Convert Word documents to PDF", @@ -553,15 +570,37 @@ export const id: TranslationKeys = { name: "PDF Metadata", description: "Read and write PDF document metadata", }, + "powerpoint-to-pdf": { + name: "PowerPoint to PDF", + description: "Convert presentations to PDF", + }, "html-to-pdf": { name: "HTML to PDF", description: "Convert an HTML file to PDF. Remote resources are disabled for privacy.", }, + "markdown-to-docx": { + name: "Markdown to Word", + description: "Convert a Markdown file to a Word document (DOCX)", + }, + "markdown-to-html": { + name: "Markdown to HTML", + description: + "Convert a Markdown file to a standalone HTML page. Remote images in the source are left as-is in the output.", + }, "markdown-to-pdf": { name: "Markdown to PDF", description: "Convert a Markdown file to a styled PDF. Remote resources are disabled for privacy.", }, + "epub-convert": { + name: "Convert EPUB", + description: + "Convert an EPUB to PDF, DOCX, HTML, or Markdown. Remote resources inside the book are not fetched.", + }, + "to-epub": { + name: "Convert to EPUB", + description: "Convert Word, Markdown, HTML, or plain text files to EPUB", + }, "csv-excel": { name: "CSV to Excel", description: "Convert between CSV and Excel (XLSX), both directions", @@ -578,6 +617,27 @@ export const id: TranslationKeys = { name: "Split CSV", description: "Split a CSV into smaller files by row count", }, + "merge-csvs": { + name: "Merge CSVs", + description: "Combine multiple CSV or TSV files with matching columns into one", + }, + "yaml-json": { + name: "YAML / JSON", + description: "Convert between YAML and JSON, both directions", + }, + "xml-to-csv": { + name: "XML to CSV", + description: "Extract repeating elements from XML into a CSV table", + }, + "create-zip": { + name: "Create ZIP", + description: "Bundle multiple files into a single ZIP archive", + }, + "extract-zip": { + name: "Extract ZIP", + description: + "Safely extract files from a ZIP archive with bomb protection. Single-file archives return the file directly.", + }, pipeline: { name: "Pipeline Builder", description: "Rangkai beberapa alat menjadi alur kerja", @@ -1917,6 +1977,35 @@ export const id: TranslationKeys = { submitBatch: "Rotate ({count} files)", progressLabel: "Rotating", }, + "convert-document": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-presentation": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-spreadsheet": { + format: "Output format", + hint: "Multi-sheet workbooks export the first sheet to CSV.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "excel-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "powerpoint-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "word-to-pdf": { submit: "Convert to PDF", submitBatch: "Convert ({count} files)", @@ -2051,12 +2140,35 @@ export const id: TranslationKeys = { submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "markdown-to-docx": { + submit: "Convert to DOCX", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "markdown-to-html": { + hint: "Remote images in the source are left as-is in the output.", + submit: "Convert to HTML", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "markdown-to-pdf": { hint: "Remote resources are disabled for privacy.", submit: "Convert to PDF", submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "epub-convert": { + format: "Output format", + hint: "Remote resources inside the book are not fetched.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "to-epub": { + submit: "Convert to EPUB", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "csv-excel": { sheet: "Worksheet number", submit: "Convert", @@ -2082,6 +2194,31 @@ export const id: TranslationKeys = { submitBatch: "Split ({count} files)", progressLabel: "Splitting", }, + "merge-csvs": { + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "yaml-json": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "xml-to-csv": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "create-zip": { + submit: "Create ZIP", + submitBatch: "Create ZIP ({count} files)", + progressLabel: "Zipping", + }, + "extract-zip": { + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, }, urlImport: { title: "Impor dari URL", diff --git a/packages/shared/src/i18n/it.ts b/packages/shared/src/i18n/it.ts index 9005b7b7..632c037e 100644 --- a/packages/shared/src/i18n/it.ts +++ b/packages/shared/src/i18n/it.ts @@ -472,6 +472,23 @@ export const it: TranslationKeys = { name: "Rotate PDF", description: "Rotate pages in a PDF", }, + "convert-document": { + name: "Convert Document", + description: "Convert between Word, OpenDocument, RTF, and plain text formats", + }, + "convert-presentation": { + name: "Convert Presentation", + description: "Convert between PowerPoint and OpenDocument presentation formats", + }, + "convert-spreadsheet": { + name: "Convert Spreadsheet", + description: + "Convert between Excel, OpenDocument, and CSV formats. Multi-sheet workbooks export the first sheet to CSV.", + }, + "excel-to-pdf": { + name: "Excel to PDF", + description: "Convert spreadsheets to PDF. Wide sheets may paginate across multiple pages.", + }, "word-to-pdf": { name: "Word to PDF", description: "Convert Word documents to PDF", @@ -552,15 +569,37 @@ export const it: TranslationKeys = { name: "PDF Metadata", description: "Read and write PDF document metadata", }, + "powerpoint-to-pdf": { + name: "PowerPoint to PDF", + description: "Convert presentations to PDF", + }, "html-to-pdf": { name: "HTML to PDF", description: "Convert an HTML file to PDF. Remote resources are disabled for privacy.", }, + "markdown-to-docx": { + name: "Markdown to Word", + description: "Convert a Markdown file to a Word document (DOCX)", + }, + "markdown-to-html": { + name: "Markdown to HTML", + description: + "Convert a Markdown file to a standalone HTML page. Remote images in the source are left as-is in the output.", + }, "markdown-to-pdf": { name: "Markdown to PDF", description: "Convert a Markdown file to a styled PDF. Remote resources are disabled for privacy.", }, + "epub-convert": { + name: "Convert EPUB", + description: + "Convert an EPUB to PDF, DOCX, HTML, or Markdown. Remote resources inside the book are not fetched.", + }, + "to-epub": { + name: "Convert to EPUB", + description: "Convert Word, Markdown, HTML, or plain text files to EPUB", + }, "csv-excel": { name: "CSV to Excel", description: "Convert between CSV and Excel (XLSX), both directions", @@ -577,6 +616,27 @@ export const it: TranslationKeys = { name: "Split CSV", description: "Split a CSV into smaller files by row count", }, + "merge-csvs": { + name: "Merge CSVs", + description: "Combine multiple CSV or TSV files with matching columns into one", + }, + "yaml-json": { + name: "YAML / JSON", + description: "Convert between YAML and JSON, both directions", + }, + "xml-to-csv": { + name: "XML to CSV", + description: "Extract repeating elements from XML into a CSV table", + }, + "create-zip": { + name: "Create ZIP", + description: "Bundle multiple files into a single ZIP archive", + }, + "extract-zip": { + name: "Extract ZIP", + description: + "Safely extract files from a ZIP archive with bomb protection. Single-file archives return the file directly.", + }, pipeline: { name: "Costruttore di Pipeline", description: "Concatena piu strumenti in un flusso di lavoro", @@ -1916,6 +1976,35 @@ export const it: TranslationKeys = { submitBatch: "Rotate ({count} files)", progressLabel: "Rotating", }, + "convert-document": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-presentation": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-spreadsheet": { + format: "Output format", + hint: "Multi-sheet workbooks export the first sheet to CSV.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "excel-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "powerpoint-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "word-to-pdf": { submit: "Convert to PDF", submitBatch: "Convert ({count} files)", @@ -2050,12 +2139,35 @@ export const it: TranslationKeys = { submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "markdown-to-docx": { + submit: "Convert to DOCX", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "markdown-to-html": { + hint: "Remote images in the source are left as-is in the output.", + submit: "Convert to HTML", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "markdown-to-pdf": { hint: "Remote resources are disabled for privacy.", submit: "Convert to PDF", submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "epub-convert": { + format: "Output format", + hint: "Remote resources inside the book are not fetched.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "to-epub": { + submit: "Convert to EPUB", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "csv-excel": { sheet: "Worksheet number", submit: "Convert", @@ -2081,6 +2193,31 @@ export const it: TranslationKeys = { submitBatch: "Split ({count} files)", progressLabel: "Splitting", }, + "merge-csvs": { + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "yaml-json": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "xml-to-csv": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "create-zip": { + submit: "Create ZIP", + submitBatch: "Create ZIP ({count} files)", + progressLabel: "Zipping", + }, + "extract-zip": { + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, }, urlImport: { title: "Importa da URL", diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index 3d00b0e0..2da9147e 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -436,6 +436,23 @@ export const ja: TranslationKeys = { name: "Rotate PDF", description: "Rotate pages in a PDF", }, + "convert-document": { + name: "Convert Document", + description: "Convert between Word, OpenDocument, RTF, and plain text formats", + }, + "convert-presentation": { + name: "Convert Presentation", + description: "Convert between PowerPoint and OpenDocument presentation formats", + }, + "convert-spreadsheet": { + name: "Convert Spreadsheet", + description: + "Convert between Excel, OpenDocument, and CSV formats. Multi-sheet workbooks export the first sheet to CSV.", + }, + "excel-to-pdf": { + name: "Excel to PDF", + description: "Convert spreadsheets to PDF. Wide sheets may paginate across multiple pages.", + }, "word-to-pdf": { name: "Word to PDF", description: "Convert Word documents to PDF", @@ -516,15 +533,37 @@ export const ja: TranslationKeys = { name: "PDF Metadata", description: "Read and write PDF document metadata", }, + "powerpoint-to-pdf": { + name: "PowerPoint to PDF", + description: "Convert presentations to PDF", + }, "html-to-pdf": { name: "HTML to PDF", description: "Convert an HTML file to PDF. Remote resources are disabled for privacy.", }, + "markdown-to-docx": { + name: "Markdown to Word", + description: "Convert a Markdown file to a Word document (DOCX)", + }, + "markdown-to-html": { + name: "Markdown to HTML", + description: + "Convert a Markdown file to a standalone HTML page. Remote images in the source are left as-is in the output.", + }, "markdown-to-pdf": { name: "Markdown to PDF", description: "Convert a Markdown file to a styled PDF. Remote resources are disabled for privacy.", }, + "epub-convert": { + name: "Convert EPUB", + description: + "Convert an EPUB to PDF, DOCX, HTML, or Markdown. Remote resources inside the book are not fetched.", + }, + "to-epub": { + name: "Convert to EPUB", + description: "Convert Word, Markdown, HTML, or plain text files to EPUB", + }, "csv-excel": { name: "CSV to Excel", description: "Convert between CSV and Excel (XLSX), both directions", @@ -541,6 +580,27 @@ export const ja: TranslationKeys = { name: "Split CSV", description: "Split a CSV into smaller files by row count", }, + "merge-csvs": { + name: "Merge CSVs", + description: "Combine multiple CSV or TSV files with matching columns into one", + }, + "yaml-json": { + name: "YAML / JSON", + description: "Convert between YAML and JSON, both directions", + }, + "xml-to-csv": { + name: "XML to CSV", + description: "Extract repeating elements from XML into a CSV table", + }, + "create-zip": { + name: "Create ZIP", + description: "Bundle multiple files into a single ZIP archive", + }, + "extract-zip": { + name: "Extract ZIP", + description: + "Safely extract files from a ZIP archive with bomb protection. Single-file archives return the file directly.", + }, pipeline: { name: "Pipelineビルダー", description: "複数のツールをワークフローに連結" }, processing: { canceled: "Processing canceled" }, mediaPlayer: { @@ -1875,6 +1935,35 @@ export const ja: TranslationKeys = { submitBatch: "Rotate ({count} files)", progressLabel: "Rotating", }, + "convert-document": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-presentation": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-spreadsheet": { + format: "Output format", + hint: "Multi-sheet workbooks export the first sheet to CSV.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "excel-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "powerpoint-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "word-to-pdf": { submit: "Convert to PDF", submitBatch: "Convert ({count} files)", @@ -2009,12 +2098,35 @@ export const ja: TranslationKeys = { submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "markdown-to-docx": { + submit: "Convert to DOCX", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "markdown-to-html": { + hint: "Remote images in the source are left as-is in the output.", + submit: "Convert to HTML", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "markdown-to-pdf": { hint: "Remote resources are disabled for privacy.", submit: "Convert to PDF", submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "epub-convert": { + format: "Output format", + hint: "Remote resources inside the book are not fetched.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "to-epub": { + submit: "Convert to EPUB", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "csv-excel": { sheet: "Worksheet number", submit: "Convert", @@ -2040,6 +2152,31 @@ export const ja: TranslationKeys = { submitBatch: "Split ({count} files)", progressLabel: "Splitting", }, + "merge-csvs": { + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "yaml-json": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "xml-to-csv": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "create-zip": { + submit: "Create ZIP", + submitBatch: "Create ZIP ({count} files)", + progressLabel: "Zipping", + }, + "extract-zip": { + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, }, urlImport: { title: "URLからインポート", diff --git a/packages/shared/src/i18n/ko.ts b/packages/shared/src/i18n/ko.ts index 9477ad58..cd32729c 100644 --- a/packages/shared/src/i18n/ko.ts +++ b/packages/shared/src/i18n/ko.ts @@ -423,6 +423,23 @@ export const ko: TranslationKeys = { name: "Rotate PDF", description: "Rotate pages in a PDF", }, + "convert-document": { + name: "Convert Document", + description: "Convert between Word, OpenDocument, RTF, and plain text formats", + }, + "convert-presentation": { + name: "Convert Presentation", + description: "Convert between PowerPoint and OpenDocument presentation formats", + }, + "convert-spreadsheet": { + name: "Convert Spreadsheet", + description: + "Convert between Excel, OpenDocument, and CSV formats. Multi-sheet workbooks export the first sheet to CSV.", + }, + "excel-to-pdf": { + name: "Excel to PDF", + description: "Convert spreadsheets to PDF. Wide sheets may paginate across multiple pages.", + }, "word-to-pdf": { name: "Word to PDF", description: "Convert Word documents to PDF", @@ -503,15 +520,37 @@ export const ko: TranslationKeys = { name: "PDF Metadata", description: "Read and write PDF document metadata", }, + "powerpoint-to-pdf": { + name: "PowerPoint to PDF", + description: "Convert presentations to PDF", + }, "html-to-pdf": { name: "HTML to PDF", description: "Convert an HTML file to PDF. Remote resources are disabled for privacy.", }, + "markdown-to-docx": { + name: "Markdown to Word", + description: "Convert a Markdown file to a Word document (DOCX)", + }, + "markdown-to-html": { + name: "Markdown to HTML", + description: + "Convert a Markdown file to a standalone HTML page. Remote images in the source are left as-is in the output.", + }, "markdown-to-pdf": { name: "Markdown to PDF", description: "Convert a Markdown file to a styled PDF. Remote resources are disabled for privacy.", }, + "epub-convert": { + name: "Convert EPUB", + description: + "Convert an EPUB to PDF, DOCX, HTML, or Markdown. Remote resources inside the book are not fetched.", + }, + "to-epub": { + name: "Convert to EPUB", + description: "Convert Word, Markdown, HTML, or plain text files to EPUB", + }, "csv-excel": { name: "CSV to Excel", description: "Convert between CSV and Excel (XLSX), both directions", @@ -528,6 +567,27 @@ export const ko: TranslationKeys = { name: "Split CSV", description: "Split a CSV into smaller files by row count", }, + "merge-csvs": { + name: "Merge CSVs", + description: "Combine multiple CSV or TSV files with matching columns into one", + }, + "yaml-json": { + name: "YAML / JSON", + description: "Convert between YAML and JSON, both directions", + }, + "xml-to-csv": { + name: "XML to CSV", + description: "Extract repeating elements from XML into a CSV table", + }, + "create-zip": { + name: "Create ZIP", + description: "Bundle multiple files into a single ZIP archive", + }, + "extract-zip": { + name: "Extract ZIP", + description: + "Safely extract files from a ZIP archive with bomb protection. Single-file archives return the file directly.", + }, pipeline: { name: "Pipeline 빌더", description: "여러 도구를 워크플로로 연결" }, processing: { canceled: "Processing canceled" }, mediaPlayer: { @@ -1860,6 +1920,35 @@ export const ko: TranslationKeys = { submitBatch: "Rotate ({count} files)", progressLabel: "Rotating", }, + "convert-document": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-presentation": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-spreadsheet": { + format: "Output format", + hint: "Multi-sheet workbooks export the first sheet to CSV.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "excel-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "powerpoint-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "word-to-pdf": { submit: "Convert to PDF", submitBatch: "Convert ({count} files)", @@ -1994,12 +2083,35 @@ export const ko: TranslationKeys = { submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "markdown-to-docx": { + submit: "Convert to DOCX", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "markdown-to-html": { + hint: "Remote images in the source are left as-is in the output.", + submit: "Convert to HTML", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "markdown-to-pdf": { hint: "Remote resources are disabled for privacy.", submit: "Convert to PDF", submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "epub-convert": { + format: "Output format", + hint: "Remote resources inside the book are not fetched.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "to-epub": { + submit: "Convert to EPUB", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "csv-excel": { sheet: "Worksheet number", submit: "Convert", @@ -2025,6 +2137,31 @@ export const ko: TranslationKeys = { submitBatch: "Split ({count} files)", progressLabel: "Splitting", }, + "merge-csvs": { + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "yaml-json": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "xml-to-csv": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "create-zip": { + submit: "Create ZIP", + submitBatch: "Create ZIP ({count} files)", + progressLabel: "Zipping", + }, + "extract-zip": { + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, }, urlImport: { title: "URL에서 가져오기", diff --git a/packages/shared/src/i18n/nl.ts b/packages/shared/src/i18n/nl.ts index 929a3552..7579d153 100644 --- a/packages/shared/src/i18n/nl.ts +++ b/packages/shared/src/i18n/nl.ts @@ -473,6 +473,23 @@ export const nl: TranslationKeys = { name: "Rotate PDF", description: "Rotate pages in a PDF", }, + "convert-document": { + name: "Convert Document", + description: "Convert between Word, OpenDocument, RTF, and plain text formats", + }, + "convert-presentation": { + name: "Convert Presentation", + description: "Convert between PowerPoint and OpenDocument presentation formats", + }, + "convert-spreadsheet": { + name: "Convert Spreadsheet", + description: + "Convert between Excel, OpenDocument, and CSV formats. Multi-sheet workbooks export the first sheet to CSV.", + }, + "excel-to-pdf": { + name: "Excel to PDF", + description: "Convert spreadsheets to PDF. Wide sheets may paginate across multiple pages.", + }, "word-to-pdf": { name: "Word to PDF", description: "Convert Word documents to PDF", @@ -553,15 +570,37 @@ export const nl: TranslationKeys = { name: "PDF Metadata", description: "Read and write PDF document metadata", }, + "powerpoint-to-pdf": { + name: "PowerPoint to PDF", + description: "Convert presentations to PDF", + }, "html-to-pdf": { name: "HTML to PDF", description: "Convert an HTML file to PDF. Remote resources are disabled for privacy.", }, + "markdown-to-docx": { + name: "Markdown to Word", + description: "Convert a Markdown file to a Word document (DOCX)", + }, + "markdown-to-html": { + name: "Markdown to HTML", + description: + "Convert a Markdown file to a standalone HTML page. Remote images in the source are left as-is in the output.", + }, "markdown-to-pdf": { name: "Markdown to PDF", description: "Convert a Markdown file to a styled PDF. Remote resources are disabled for privacy.", }, + "epub-convert": { + name: "Convert EPUB", + description: + "Convert an EPUB to PDF, DOCX, HTML, or Markdown. Remote resources inside the book are not fetched.", + }, + "to-epub": { + name: "Convert to EPUB", + description: "Convert Word, Markdown, HTML, or plain text files to EPUB", + }, "csv-excel": { name: "CSV to Excel", description: "Convert between CSV and Excel (XLSX), both directions", @@ -578,6 +617,27 @@ export const nl: TranslationKeys = { name: "Split CSV", description: "Split a CSV into smaller files by row count", }, + "merge-csvs": { + name: "Merge CSVs", + description: "Combine multiple CSV or TSV files with matching columns into one", + }, + "yaml-json": { + name: "YAML / JSON", + description: "Convert between YAML and JSON, both directions", + }, + "xml-to-csv": { + name: "XML to CSV", + description: "Extract repeating elements from XML into a CSV table", + }, + "create-zip": { + name: "Create ZIP", + description: "Bundle multiple files into a single ZIP archive", + }, + "extract-zip": { + name: "Extract ZIP", + description: + "Safely extract files from a ZIP archive with bomb protection. Single-file archives return the file directly.", + }, pipeline: { name: "Pipeline-builder", description: "Meerdere tools koppelen tot een workflow", @@ -1919,6 +1979,35 @@ export const nl: TranslationKeys = { submitBatch: "Rotate ({count} files)", progressLabel: "Rotating", }, + "convert-document": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-presentation": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-spreadsheet": { + format: "Output format", + hint: "Multi-sheet workbooks export the first sheet to CSV.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "excel-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "powerpoint-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "word-to-pdf": { submit: "Convert to PDF", submitBatch: "Convert ({count} files)", @@ -2053,12 +2142,35 @@ export const nl: TranslationKeys = { submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "markdown-to-docx": { + submit: "Convert to DOCX", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "markdown-to-html": { + hint: "Remote images in the source are left as-is in the output.", + submit: "Convert to HTML", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "markdown-to-pdf": { hint: "Remote resources are disabled for privacy.", submit: "Convert to PDF", submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "epub-convert": { + format: "Output format", + hint: "Remote resources inside the book are not fetched.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "to-epub": { + submit: "Convert to EPUB", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "csv-excel": { sheet: "Worksheet number", submit: "Convert", @@ -2084,6 +2196,31 @@ export const nl: TranslationKeys = { submitBatch: "Split ({count} files)", progressLabel: "Splitting", }, + "merge-csvs": { + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "yaml-json": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "xml-to-csv": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "create-zip": { + submit: "Create ZIP", + submitBatch: "Create ZIP ({count} files)", + progressLabel: "Zipping", + }, + "extract-zip": { + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, }, urlImport: { title: "Importeren van URLs", diff --git a/packages/shared/src/i18n/pl.ts b/packages/shared/src/i18n/pl.ts index 53508222..c54cc83b 100644 --- a/packages/shared/src/i18n/pl.ts +++ b/packages/shared/src/i18n/pl.ts @@ -474,6 +474,23 @@ export const pl: TranslationKeys = { name: "Rotate PDF", description: "Rotate pages in a PDF", }, + "convert-document": { + name: "Convert Document", + description: "Convert between Word, OpenDocument, RTF, and plain text formats", + }, + "convert-presentation": { + name: "Convert Presentation", + description: "Convert between PowerPoint and OpenDocument presentation formats", + }, + "convert-spreadsheet": { + name: "Convert Spreadsheet", + description: + "Convert between Excel, OpenDocument, and CSV formats. Multi-sheet workbooks export the first sheet to CSV.", + }, + "excel-to-pdf": { + name: "Excel to PDF", + description: "Convert spreadsheets to PDF. Wide sheets may paginate across multiple pages.", + }, "word-to-pdf": { name: "Word to PDF", description: "Convert Word documents to PDF", @@ -554,15 +571,37 @@ export const pl: TranslationKeys = { name: "PDF Metadata", description: "Read and write PDF document metadata", }, + "powerpoint-to-pdf": { + name: "PowerPoint to PDF", + description: "Convert presentations to PDF", + }, "html-to-pdf": { name: "HTML to PDF", description: "Convert an HTML file to PDF. Remote resources are disabled for privacy.", }, + "markdown-to-docx": { + name: "Markdown to Word", + description: "Convert a Markdown file to a Word document (DOCX)", + }, + "markdown-to-html": { + name: "Markdown to HTML", + description: + "Convert a Markdown file to a standalone HTML page. Remote images in the source are left as-is in the output.", + }, "markdown-to-pdf": { name: "Markdown to PDF", description: "Convert a Markdown file to a styled PDF. Remote resources are disabled for privacy.", }, + "epub-convert": { + name: "Convert EPUB", + description: + "Convert an EPUB to PDF, DOCX, HTML, or Markdown. Remote resources inside the book are not fetched.", + }, + "to-epub": { + name: "Convert to EPUB", + description: "Convert Word, Markdown, HTML, or plain text files to EPUB", + }, "csv-excel": { name: "CSV to Excel", description: "Convert between CSV and Excel (XLSX), both directions", @@ -579,6 +618,27 @@ export const pl: TranslationKeys = { name: "Split CSV", description: "Split a CSV into smaller files by row count", }, + "merge-csvs": { + name: "Merge CSVs", + description: "Combine multiple CSV or TSV files with matching columns into one", + }, + "yaml-json": { + name: "YAML / JSON", + description: "Convert between YAML and JSON, both directions", + }, + "xml-to-csv": { + name: "XML to CSV", + description: "Extract repeating elements from XML into a CSV table", + }, + "create-zip": { + name: "Create ZIP", + description: "Bundle multiple files into a single ZIP archive", + }, + "extract-zip": { + name: "Extract ZIP", + description: + "Safely extract files from a ZIP archive with bomb protection. Single-file archives return the file directly.", + }, pipeline: { name: "Konstruktor Pipeline", description: "Łączenie wielu narzędzi w przepływ pracy", @@ -1920,6 +1980,35 @@ export const pl: TranslationKeys = { submitBatch: "Rotate ({count} files)", progressLabel: "Rotating", }, + "convert-document": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-presentation": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-spreadsheet": { + format: "Output format", + hint: "Multi-sheet workbooks export the first sheet to CSV.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "excel-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "powerpoint-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "word-to-pdf": { submit: "Convert to PDF", submitBatch: "Convert ({count} files)", @@ -2054,12 +2143,35 @@ export const pl: TranslationKeys = { submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "markdown-to-docx": { + submit: "Convert to DOCX", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "markdown-to-html": { + hint: "Remote images in the source are left as-is in the output.", + submit: "Convert to HTML", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "markdown-to-pdf": { hint: "Remote resources are disabled for privacy.", submit: "Convert to PDF", submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "epub-convert": { + format: "Output format", + hint: "Remote resources inside the book are not fetched.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "to-epub": { + submit: "Convert to EPUB", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "csv-excel": { sheet: "Worksheet number", submit: "Convert", @@ -2085,6 +2197,31 @@ export const pl: TranslationKeys = { submitBatch: "Split ({count} files)", progressLabel: "Splitting", }, + "merge-csvs": { + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "yaml-json": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "xml-to-csv": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "create-zip": { + submit: "Create ZIP", + submitBatch: "Create ZIP ({count} files)", + progressLabel: "Zipping", + }, + "extract-zip": { + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, }, urlImport: { title: "Import z adresów URL", diff --git a/packages/shared/src/i18n/pt-BR.ts b/packages/shared/src/i18n/pt-BR.ts index 0a708e99..f2cc66cd 100644 --- a/packages/shared/src/i18n/pt-BR.ts +++ b/packages/shared/src/i18n/pt-BR.ts @@ -471,6 +471,23 @@ export const ptBR: TranslationKeys = { name: "Rotate PDF", description: "Rotate pages in a PDF", }, + "convert-document": { + name: "Convert Document", + description: "Convert between Word, OpenDocument, RTF, and plain text formats", + }, + "convert-presentation": { + name: "Convert Presentation", + description: "Convert between PowerPoint and OpenDocument presentation formats", + }, + "convert-spreadsheet": { + name: "Convert Spreadsheet", + description: + "Convert between Excel, OpenDocument, and CSV formats. Multi-sheet workbooks export the first sheet to CSV.", + }, + "excel-to-pdf": { + name: "Excel to PDF", + description: "Convert spreadsheets to PDF. Wide sheets may paginate across multiple pages.", + }, "word-to-pdf": { name: "Word to PDF", description: "Convert Word documents to PDF", @@ -551,15 +568,37 @@ export const ptBR: TranslationKeys = { name: "PDF Metadata", description: "Read and write PDF document metadata", }, + "powerpoint-to-pdf": { + name: "PowerPoint to PDF", + description: "Convert presentations to PDF", + }, "html-to-pdf": { name: "HTML to PDF", description: "Convert an HTML file to PDF. Remote resources are disabled for privacy.", }, + "markdown-to-docx": { + name: "Markdown to Word", + description: "Convert a Markdown file to a Word document (DOCX)", + }, + "markdown-to-html": { + name: "Markdown to HTML", + description: + "Convert a Markdown file to a standalone HTML page. Remote images in the source are left as-is in the output.", + }, "markdown-to-pdf": { name: "Markdown to PDF", description: "Convert a Markdown file to a styled PDF. Remote resources are disabled for privacy.", }, + "epub-convert": { + name: "Convert EPUB", + description: + "Convert an EPUB to PDF, DOCX, HTML, or Markdown. Remote resources inside the book are not fetched.", + }, + "to-epub": { + name: "Convert to EPUB", + description: "Convert Word, Markdown, HTML, or plain text files to EPUB", + }, "csv-excel": { name: "CSV to Excel", description: "Convert between CSV and Excel (XLSX), both directions", @@ -576,6 +615,27 @@ export const ptBR: TranslationKeys = { name: "Split CSV", description: "Split a CSV into smaller files by row count", }, + "merge-csvs": { + name: "Merge CSVs", + description: "Combine multiple CSV or TSV files with matching columns into one", + }, + "yaml-json": { + name: "YAML / JSON", + description: "Convert between YAML and JSON, both directions", + }, + "xml-to-csv": { + name: "XML to CSV", + description: "Extract repeating elements from XML into a CSV table", + }, + "create-zip": { + name: "Create ZIP", + description: "Bundle multiple files into a single ZIP archive", + }, + "extract-zip": { + name: "Extract ZIP", + description: + "Safely extract files from a ZIP archive with bomb protection. Single-file archives return the file directly.", + }, pipeline: { name: "Construtor de Pipeline", description: "Encadeie varias ferramentas em um fluxo de trabalho", @@ -1916,6 +1976,35 @@ export const ptBR: TranslationKeys = { submitBatch: "Rotate ({count} files)", progressLabel: "Rotating", }, + "convert-document": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-presentation": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-spreadsheet": { + format: "Output format", + hint: "Multi-sheet workbooks export the first sheet to CSV.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "excel-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "powerpoint-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "word-to-pdf": { submit: "Convert to PDF", submitBatch: "Convert ({count} files)", @@ -2050,12 +2139,35 @@ export const ptBR: TranslationKeys = { submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "markdown-to-docx": { + submit: "Convert to DOCX", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "markdown-to-html": { + hint: "Remote images in the source are left as-is in the output.", + submit: "Convert to HTML", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "markdown-to-pdf": { hint: "Remote resources are disabled for privacy.", submit: "Convert to PDF", submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "epub-convert": { + format: "Output format", + hint: "Remote resources inside the book are not fetched.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "to-epub": { + submit: "Convert to EPUB", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "csv-excel": { sheet: "Worksheet number", submit: "Convert", @@ -2081,6 +2193,31 @@ export const ptBR: TranslationKeys = { submitBatch: "Split ({count} files)", progressLabel: "Splitting", }, + "merge-csvs": { + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "yaml-json": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "xml-to-csv": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "create-zip": { + submit: "Create ZIP", + submitBatch: "Create ZIP ({count} files)", + progressLabel: "Zipping", + }, + "extract-zip": { + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, }, urlImport: { title: "Importar de URLs", diff --git a/packages/shared/src/i18n/ru.ts b/packages/shared/src/i18n/ru.ts index 190261ba..b44975ea 100644 --- a/packages/shared/src/i18n/ru.ts +++ b/packages/shared/src/i18n/ru.ts @@ -473,6 +473,23 @@ export const ru: TranslationKeys = { name: "Rotate PDF", description: "Rotate pages in a PDF", }, + "convert-document": { + name: "Convert Document", + description: "Convert between Word, OpenDocument, RTF, and plain text formats", + }, + "convert-presentation": { + name: "Convert Presentation", + description: "Convert between PowerPoint and OpenDocument presentation formats", + }, + "convert-spreadsheet": { + name: "Convert Spreadsheet", + description: + "Convert between Excel, OpenDocument, and CSV formats. Multi-sheet workbooks export the first sheet to CSV.", + }, + "excel-to-pdf": { + name: "Excel to PDF", + description: "Convert spreadsheets to PDF. Wide sheets may paginate across multiple pages.", + }, "word-to-pdf": { name: "Word to PDF", description: "Convert Word documents to PDF", @@ -553,15 +570,37 @@ export const ru: TranslationKeys = { name: "PDF Metadata", description: "Read and write PDF document metadata", }, + "powerpoint-to-pdf": { + name: "PowerPoint to PDF", + description: "Convert presentations to PDF", + }, "html-to-pdf": { name: "HTML to PDF", description: "Convert an HTML file to PDF. Remote resources are disabled for privacy.", }, + "markdown-to-docx": { + name: "Markdown to Word", + description: "Convert a Markdown file to a Word document (DOCX)", + }, + "markdown-to-html": { + name: "Markdown to HTML", + description: + "Convert a Markdown file to a standalone HTML page. Remote images in the source are left as-is in the output.", + }, "markdown-to-pdf": { name: "Markdown to PDF", description: "Convert a Markdown file to a styled PDF. Remote resources are disabled for privacy.", }, + "epub-convert": { + name: "Convert EPUB", + description: + "Convert an EPUB to PDF, DOCX, HTML, or Markdown. Remote resources inside the book are not fetched.", + }, + "to-epub": { + name: "Convert to EPUB", + description: "Convert Word, Markdown, HTML, or plain text files to EPUB", + }, "csv-excel": { name: "CSV to Excel", description: "Convert between CSV and Excel (XLSX), both directions", @@ -578,6 +617,27 @@ export const ru: TranslationKeys = { name: "Split CSV", description: "Split a CSV into smaller files by row count", }, + "merge-csvs": { + name: "Merge CSVs", + description: "Combine multiple CSV or TSV files with matching columns into one", + }, + "yaml-json": { + name: "YAML / JSON", + description: "Convert between YAML and JSON, both directions", + }, + "xml-to-csv": { + name: "XML to CSV", + description: "Extract repeating elements from XML into a CSV table", + }, + "create-zip": { + name: "Create ZIP", + description: "Bundle multiple files into a single ZIP archive", + }, + "extract-zip": { + name: "Extract ZIP", + description: + "Safely extract files from a ZIP archive with bomb protection. Single-file archives return the file directly.", + }, pipeline: { name: "Конструктор Pipeline", description: "Объединение нескольких инструментов в рабочий процесс", @@ -1918,6 +1978,35 @@ export const ru: TranslationKeys = { submitBatch: "Rotate ({count} files)", progressLabel: "Rotating", }, + "convert-document": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-presentation": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-spreadsheet": { + format: "Output format", + hint: "Multi-sheet workbooks export the first sheet to CSV.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "excel-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "powerpoint-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "word-to-pdf": { submit: "Convert to PDF", submitBatch: "Convert ({count} files)", @@ -2052,12 +2141,35 @@ export const ru: TranslationKeys = { submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "markdown-to-docx": { + submit: "Convert to DOCX", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "markdown-to-html": { + hint: "Remote images in the source are left as-is in the output.", + submit: "Convert to HTML", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "markdown-to-pdf": { hint: "Remote resources are disabled for privacy.", submit: "Convert to PDF", submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "epub-convert": { + format: "Output format", + hint: "Remote resources inside the book are not fetched.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "to-epub": { + submit: "Convert to EPUB", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "csv-excel": { sheet: "Worksheet number", submit: "Convert", @@ -2083,6 +2195,31 @@ export const ru: TranslationKeys = { submitBatch: "Split ({count} files)", progressLabel: "Splitting", }, + "merge-csvs": { + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "yaml-json": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "xml-to-csv": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "create-zip": { + submit: "Create ZIP", + submitBatch: "Create ZIP ({count} files)", + progressLabel: "Zipping", + }, + "extract-zip": { + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, }, urlImport: { title: "Импорт по URL", diff --git a/packages/shared/src/i18n/sv.ts b/packages/shared/src/i18n/sv.ts index cd1c14ec..cd28b85e 100644 --- a/packages/shared/src/i18n/sv.ts +++ b/packages/shared/src/i18n/sv.ts @@ -471,6 +471,23 @@ export const sv: TranslationKeys = { name: "Rotate PDF", description: "Rotate pages in a PDF", }, + "convert-document": { + name: "Convert Document", + description: "Convert between Word, OpenDocument, RTF, and plain text formats", + }, + "convert-presentation": { + name: "Convert Presentation", + description: "Convert between PowerPoint and OpenDocument presentation formats", + }, + "convert-spreadsheet": { + name: "Convert Spreadsheet", + description: + "Convert between Excel, OpenDocument, and CSV formats. Multi-sheet workbooks export the first sheet to CSV.", + }, + "excel-to-pdf": { + name: "Excel to PDF", + description: "Convert spreadsheets to PDF. Wide sheets may paginate across multiple pages.", + }, "word-to-pdf": { name: "Word to PDF", description: "Convert Word documents to PDF", @@ -551,15 +568,37 @@ export const sv: TranslationKeys = { name: "PDF Metadata", description: "Read and write PDF document metadata", }, + "powerpoint-to-pdf": { + name: "PowerPoint to PDF", + description: "Convert presentations to PDF", + }, "html-to-pdf": { name: "HTML to PDF", description: "Convert an HTML file to PDF. Remote resources are disabled for privacy.", }, + "markdown-to-docx": { + name: "Markdown to Word", + description: "Convert a Markdown file to a Word document (DOCX)", + }, + "markdown-to-html": { + name: "Markdown to HTML", + description: + "Convert a Markdown file to a standalone HTML page. Remote images in the source are left as-is in the output.", + }, "markdown-to-pdf": { name: "Markdown to PDF", description: "Convert a Markdown file to a styled PDF. Remote resources are disabled for privacy.", }, + "epub-convert": { + name: "Convert EPUB", + description: + "Convert an EPUB to PDF, DOCX, HTML, or Markdown. Remote resources inside the book are not fetched.", + }, + "to-epub": { + name: "Convert to EPUB", + description: "Convert Word, Markdown, HTML, or plain text files to EPUB", + }, "csv-excel": { name: "CSV to Excel", description: "Convert between CSV and Excel (XLSX), both directions", @@ -576,6 +615,27 @@ export const sv: TranslationKeys = { name: "Split CSV", description: "Split a CSV into smaller files by row count", }, + "merge-csvs": { + name: "Merge CSVs", + description: "Combine multiple CSV or TSV files with matching columns into one", + }, + "yaml-json": { + name: "YAML / JSON", + description: "Convert between YAML and JSON, both directions", + }, + "xml-to-csv": { + name: "XML to CSV", + description: "Extract repeating elements from XML into a CSV table", + }, + "create-zip": { + name: "Create ZIP", + description: "Bundle multiple files into a single ZIP archive", + }, + "extract-zip": { + name: "Extract ZIP", + description: + "Safely extract files from a ZIP archive with bomb protection. Single-file archives return the file directly.", + }, pipeline: { name: "Pipeline-byggare", description: "Kedja samman flera verktyg till ett arbetsflode", @@ -1915,6 +1975,35 @@ export const sv: TranslationKeys = { submitBatch: "Rotate ({count} files)", progressLabel: "Rotating", }, + "convert-document": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-presentation": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-spreadsheet": { + format: "Output format", + hint: "Multi-sheet workbooks export the first sheet to CSV.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "excel-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "powerpoint-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "word-to-pdf": { submit: "Convert to PDF", submitBatch: "Convert ({count} files)", @@ -2049,12 +2138,35 @@ export const sv: TranslationKeys = { submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "markdown-to-docx": { + submit: "Convert to DOCX", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "markdown-to-html": { + hint: "Remote images in the source are left as-is in the output.", + submit: "Convert to HTML", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "markdown-to-pdf": { hint: "Remote resources are disabled for privacy.", submit: "Convert to PDF", submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "epub-convert": { + format: "Output format", + hint: "Remote resources inside the book are not fetched.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "to-epub": { + submit: "Convert to EPUB", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "csv-excel": { sheet: "Worksheet number", submit: "Convert", @@ -2080,6 +2192,31 @@ export const sv: TranslationKeys = { submitBatch: "Split ({count} files)", progressLabel: "Splitting", }, + "merge-csvs": { + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "yaml-json": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "xml-to-csv": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "create-zip": { + submit: "Create ZIP", + submitBatch: "Create ZIP ({count} files)", + progressLabel: "Zipping", + }, + "extract-zip": { + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, }, urlImport: { title: "Importera fran URL:er", diff --git a/packages/shared/src/i18n/th.ts b/packages/shared/src/i18n/th.ts index 232ebb06..3e080ad9 100644 --- a/packages/shared/src/i18n/th.ts +++ b/packages/shared/src/i18n/th.ts @@ -466,6 +466,23 @@ export const th: TranslationKeys = { name: "Rotate PDF", description: "Rotate pages in a PDF", }, + "convert-document": { + name: "Convert Document", + description: "Convert between Word, OpenDocument, RTF, and plain text formats", + }, + "convert-presentation": { + name: "Convert Presentation", + description: "Convert between PowerPoint and OpenDocument presentation formats", + }, + "convert-spreadsheet": { + name: "Convert Spreadsheet", + description: + "Convert between Excel, OpenDocument, and CSV formats. Multi-sheet workbooks export the first sheet to CSV.", + }, + "excel-to-pdf": { + name: "Excel to PDF", + description: "Convert spreadsheets to PDF. Wide sheets may paginate across multiple pages.", + }, "word-to-pdf": { name: "Word to PDF", description: "Convert Word documents to PDF", @@ -546,15 +563,37 @@ export const th: TranslationKeys = { name: "PDF Metadata", description: "Read and write PDF document metadata", }, + "powerpoint-to-pdf": { + name: "PowerPoint to PDF", + description: "Convert presentations to PDF", + }, "html-to-pdf": { name: "HTML to PDF", description: "Convert an HTML file to PDF. Remote resources are disabled for privacy.", }, + "markdown-to-docx": { + name: "Markdown to Word", + description: "Convert a Markdown file to a Word document (DOCX)", + }, + "markdown-to-html": { + name: "Markdown to HTML", + description: + "Convert a Markdown file to a standalone HTML page. Remote images in the source are left as-is in the output.", + }, "markdown-to-pdf": { name: "Markdown to PDF", description: "Convert a Markdown file to a styled PDF. Remote resources are disabled for privacy.", }, + "epub-convert": { + name: "Convert EPUB", + description: + "Convert an EPUB to PDF, DOCX, HTML, or Markdown. Remote resources inside the book are not fetched.", + }, + "to-epub": { + name: "Convert to EPUB", + description: "Convert Word, Markdown, HTML, or plain text files to EPUB", + }, "csv-excel": { name: "CSV to Excel", description: "Convert between CSV and Excel (XLSX), both directions", @@ -571,6 +610,27 @@ export const th: TranslationKeys = { name: "Split CSV", description: "Split a CSV into smaller files by row count", }, + "merge-csvs": { + name: "Merge CSVs", + description: "Combine multiple CSV or TSV files with matching columns into one", + }, + "yaml-json": { + name: "YAML / JSON", + description: "Convert between YAML and JSON, both directions", + }, + "xml-to-csv": { + name: "XML to CSV", + description: "Extract repeating elements from XML into a CSV table", + }, + "create-zip": { + name: "Create ZIP", + description: "Bundle multiple files into a single ZIP archive", + }, + "extract-zip": { + name: "Extract ZIP", + description: + "Safely extract files from a ZIP archive with bomb protection. Single-file archives return the file directly.", + }, pipeline: { name: "ตัวสร้าง Pipeline", description: "เชื่อมต่อหลายเครื่องมือเป็นขั้นตอนทำงาน", @@ -1898,6 +1958,35 @@ export const th: TranslationKeys = { submitBatch: "Rotate ({count} files)", progressLabel: "Rotating", }, + "convert-document": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-presentation": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-spreadsheet": { + format: "Output format", + hint: "Multi-sheet workbooks export the first sheet to CSV.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "excel-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "powerpoint-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "word-to-pdf": { submit: "Convert to PDF", submitBatch: "Convert ({count} files)", @@ -2032,12 +2121,35 @@ export const th: TranslationKeys = { submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "markdown-to-docx": { + submit: "Convert to DOCX", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "markdown-to-html": { + hint: "Remote images in the source are left as-is in the output.", + submit: "Convert to HTML", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "markdown-to-pdf": { hint: "Remote resources are disabled for privacy.", submit: "Convert to PDF", submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "epub-convert": { + format: "Output format", + hint: "Remote resources inside the book are not fetched.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "to-epub": { + submit: "Convert to EPUB", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "csv-excel": { sheet: "Worksheet number", submit: "Convert", @@ -2063,6 +2175,31 @@ export const th: TranslationKeys = { submitBatch: "Split ({count} files)", progressLabel: "Splitting", }, + "merge-csvs": { + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "yaml-json": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "xml-to-csv": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "create-zip": { + submit: "Create ZIP", + submitBatch: "Create ZIP ({count} files)", + progressLabel: "Zipping", + }, + "extract-zip": { + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, }, urlImport: { title: "นำเข้าจาก URL", diff --git a/packages/shared/src/i18n/tr.ts b/packages/shared/src/i18n/tr.ts index d56b389e..7c33fab7 100644 --- a/packages/shared/src/i18n/tr.ts +++ b/packages/shared/src/i18n/tr.ts @@ -474,6 +474,23 @@ export const tr: TranslationKeys = { name: "Rotate PDF", description: "Rotate pages in a PDF", }, + "convert-document": { + name: "Convert Document", + description: "Convert between Word, OpenDocument, RTF, and plain text formats", + }, + "convert-presentation": { + name: "Convert Presentation", + description: "Convert between PowerPoint and OpenDocument presentation formats", + }, + "convert-spreadsheet": { + name: "Convert Spreadsheet", + description: + "Convert between Excel, OpenDocument, and CSV formats. Multi-sheet workbooks export the first sheet to CSV.", + }, + "excel-to-pdf": { + name: "Excel to PDF", + description: "Convert spreadsheets to PDF. Wide sheets may paginate across multiple pages.", + }, "word-to-pdf": { name: "Word to PDF", description: "Convert Word documents to PDF", @@ -554,15 +571,37 @@ export const tr: TranslationKeys = { name: "PDF Metadata", description: "Read and write PDF document metadata", }, + "powerpoint-to-pdf": { + name: "PowerPoint to PDF", + description: "Convert presentations to PDF", + }, "html-to-pdf": { name: "HTML to PDF", description: "Convert an HTML file to PDF. Remote resources are disabled for privacy.", }, + "markdown-to-docx": { + name: "Markdown to Word", + description: "Convert a Markdown file to a Word document (DOCX)", + }, + "markdown-to-html": { + name: "Markdown to HTML", + description: + "Convert a Markdown file to a standalone HTML page. Remote images in the source are left as-is in the output.", + }, "markdown-to-pdf": { name: "Markdown to PDF", description: "Convert a Markdown file to a styled PDF. Remote resources are disabled for privacy.", }, + "epub-convert": { + name: "Convert EPUB", + description: + "Convert an EPUB to PDF, DOCX, HTML, or Markdown. Remote resources inside the book are not fetched.", + }, + "to-epub": { + name: "Convert to EPUB", + description: "Convert Word, Markdown, HTML, or plain text files to EPUB", + }, "csv-excel": { name: "CSV to Excel", description: "Convert between CSV and Excel (XLSX), both directions", @@ -579,6 +618,27 @@ export const tr: TranslationKeys = { name: "Split CSV", description: "Split a CSV into smaller files by row count", }, + "merge-csvs": { + name: "Merge CSVs", + description: "Combine multiple CSV or TSV files with matching columns into one", + }, + "yaml-json": { + name: "YAML / JSON", + description: "Convert between YAML and JSON, both directions", + }, + "xml-to-csv": { + name: "XML to CSV", + description: "Extract repeating elements from XML into a CSV table", + }, + "create-zip": { + name: "Create ZIP", + description: "Bundle multiple files into a single ZIP archive", + }, + "extract-zip": { + name: "Extract ZIP", + description: + "Safely extract files from a ZIP archive with bomb protection. Single-file archives return the file directly.", + }, pipeline: { name: "Pipeline Oluşturucu", description: "Birden fazla aracı bir iş akışında zincirleyin", @@ -1920,6 +1980,35 @@ export const tr: TranslationKeys = { submitBatch: "Rotate ({count} files)", progressLabel: "Rotating", }, + "convert-document": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-presentation": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-spreadsheet": { + format: "Output format", + hint: "Multi-sheet workbooks export the first sheet to CSV.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "excel-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "powerpoint-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "word-to-pdf": { submit: "Convert to PDF", submitBatch: "Convert ({count} files)", @@ -2054,12 +2143,35 @@ export const tr: TranslationKeys = { submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "markdown-to-docx": { + submit: "Convert to DOCX", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "markdown-to-html": { + hint: "Remote images in the source are left as-is in the output.", + submit: "Convert to HTML", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "markdown-to-pdf": { hint: "Remote resources are disabled for privacy.", submit: "Convert to PDF", submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "epub-convert": { + format: "Output format", + hint: "Remote resources inside the book are not fetched.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "to-epub": { + submit: "Convert to EPUB", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "csv-excel": { sheet: "Worksheet number", submit: "Convert", @@ -2085,6 +2197,31 @@ export const tr: TranslationKeys = { submitBatch: "Split ({count} files)", progressLabel: "Splitting", }, + "merge-csvs": { + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "yaml-json": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "xml-to-csv": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "create-zip": { + submit: "Create ZIP", + submitBatch: "Create ZIP ({count} files)", + progressLabel: "Zipping", + }, + "extract-zip": { + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, }, urlImport: { title: "URL'lerden İçe Aktar", diff --git a/packages/shared/src/i18n/uk.ts b/packages/shared/src/i18n/uk.ts index 152a7554..ad219713 100644 --- a/packages/shared/src/i18n/uk.ts +++ b/packages/shared/src/i18n/uk.ts @@ -473,6 +473,23 @@ export const uk: TranslationKeys = { name: "Rotate PDF", description: "Rotate pages in a PDF", }, + "convert-document": { + name: "Convert Document", + description: "Convert between Word, OpenDocument, RTF, and plain text formats", + }, + "convert-presentation": { + name: "Convert Presentation", + description: "Convert between PowerPoint and OpenDocument presentation formats", + }, + "convert-spreadsheet": { + name: "Convert Spreadsheet", + description: + "Convert between Excel, OpenDocument, and CSV formats. Multi-sheet workbooks export the first sheet to CSV.", + }, + "excel-to-pdf": { + name: "Excel to PDF", + description: "Convert spreadsheets to PDF. Wide sheets may paginate across multiple pages.", + }, "word-to-pdf": { name: "Word to PDF", description: "Convert Word documents to PDF", @@ -553,15 +570,37 @@ export const uk: TranslationKeys = { name: "PDF Metadata", description: "Read and write PDF document metadata", }, + "powerpoint-to-pdf": { + name: "PowerPoint to PDF", + description: "Convert presentations to PDF", + }, "html-to-pdf": { name: "HTML to PDF", description: "Convert an HTML file to PDF. Remote resources are disabled for privacy.", }, + "markdown-to-docx": { + name: "Markdown to Word", + description: "Convert a Markdown file to a Word document (DOCX)", + }, + "markdown-to-html": { + name: "Markdown to HTML", + description: + "Convert a Markdown file to a standalone HTML page. Remote images in the source are left as-is in the output.", + }, "markdown-to-pdf": { name: "Markdown to PDF", description: "Convert a Markdown file to a styled PDF. Remote resources are disabled for privacy.", }, + "epub-convert": { + name: "Convert EPUB", + description: + "Convert an EPUB to PDF, DOCX, HTML, or Markdown. Remote resources inside the book are not fetched.", + }, + "to-epub": { + name: "Convert to EPUB", + description: "Convert Word, Markdown, HTML, or plain text files to EPUB", + }, "csv-excel": { name: "CSV to Excel", description: "Convert between CSV and Excel (XLSX), both directions", @@ -578,6 +617,27 @@ export const uk: TranslationKeys = { name: "Split CSV", description: "Split a CSV into smaller files by row count", }, + "merge-csvs": { + name: "Merge CSVs", + description: "Combine multiple CSV or TSV files with matching columns into one", + }, + "yaml-json": { + name: "YAML / JSON", + description: "Convert between YAML and JSON, both directions", + }, + "xml-to-csv": { + name: "XML to CSV", + description: "Extract repeating elements from XML into a CSV table", + }, + "create-zip": { + name: "Create ZIP", + description: "Bundle multiple files into a single ZIP archive", + }, + "extract-zip": { + name: "Extract ZIP", + description: + "Safely extract files from a ZIP archive with bomb protection. Single-file archives return the file directly.", + }, pipeline: { name: "Конструктор Pipeline", description: "Об'єднання кількох інструментів у робочий процес", @@ -1918,6 +1978,35 @@ export const uk: TranslationKeys = { submitBatch: "Rotate ({count} files)", progressLabel: "Rotating", }, + "convert-document": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-presentation": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-spreadsheet": { + format: "Output format", + hint: "Multi-sheet workbooks export the first sheet to CSV.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "excel-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "powerpoint-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "word-to-pdf": { submit: "Convert to PDF", submitBatch: "Convert ({count} files)", @@ -2052,12 +2141,35 @@ export const uk: TranslationKeys = { submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "markdown-to-docx": { + submit: "Convert to DOCX", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "markdown-to-html": { + hint: "Remote images in the source are left as-is in the output.", + submit: "Convert to HTML", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "markdown-to-pdf": { hint: "Remote resources are disabled for privacy.", submit: "Convert to PDF", submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "epub-convert": { + format: "Output format", + hint: "Remote resources inside the book are not fetched.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "to-epub": { + submit: "Convert to EPUB", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "csv-excel": { sheet: "Worksheet number", submit: "Convert", @@ -2083,6 +2195,31 @@ export const uk: TranslationKeys = { submitBatch: "Split ({count} files)", progressLabel: "Splitting", }, + "merge-csvs": { + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "yaml-json": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "xml-to-csv": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "create-zip": { + submit: "Create ZIP", + submitBatch: "Create ZIP ({count} files)", + progressLabel: "Zipping", + }, + "extract-zip": { + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, }, urlImport: { title: "Імпорт за URL", diff --git a/packages/shared/src/i18n/vi.ts b/packages/shared/src/i18n/vi.ts index 1793f0eb..6877b31c 100644 --- a/packages/shared/src/i18n/vi.ts +++ b/packages/shared/src/i18n/vi.ts @@ -474,6 +474,23 @@ export const vi: TranslationKeys = { name: "Rotate PDF", description: "Rotate pages in a PDF", }, + "convert-document": { + name: "Convert Document", + description: "Convert between Word, OpenDocument, RTF, and plain text formats", + }, + "convert-presentation": { + name: "Convert Presentation", + description: "Convert between PowerPoint and OpenDocument presentation formats", + }, + "convert-spreadsheet": { + name: "Convert Spreadsheet", + description: + "Convert between Excel, OpenDocument, and CSV formats. Multi-sheet workbooks export the first sheet to CSV.", + }, + "excel-to-pdf": { + name: "Excel to PDF", + description: "Convert spreadsheets to PDF. Wide sheets may paginate across multiple pages.", + }, "word-to-pdf": { name: "Word to PDF", description: "Convert Word documents to PDF", @@ -554,15 +571,37 @@ export const vi: TranslationKeys = { name: "PDF Metadata", description: "Read and write PDF document metadata", }, + "powerpoint-to-pdf": { + name: "PowerPoint to PDF", + description: "Convert presentations to PDF", + }, "html-to-pdf": { name: "HTML to PDF", description: "Convert an HTML file to PDF. Remote resources are disabled for privacy.", }, + "markdown-to-docx": { + name: "Markdown to Word", + description: "Convert a Markdown file to a Word document (DOCX)", + }, + "markdown-to-html": { + name: "Markdown to HTML", + description: + "Convert a Markdown file to a standalone HTML page. Remote images in the source are left as-is in the output.", + }, "markdown-to-pdf": { name: "Markdown to PDF", description: "Convert a Markdown file to a styled PDF. Remote resources are disabled for privacy.", }, + "epub-convert": { + name: "Convert EPUB", + description: + "Convert an EPUB to PDF, DOCX, HTML, or Markdown. Remote resources inside the book are not fetched.", + }, + "to-epub": { + name: "Convert to EPUB", + description: "Convert Word, Markdown, HTML, or plain text files to EPUB", + }, "csv-excel": { name: "CSV to Excel", description: "Convert between CSV and Excel (XLSX), both directions", @@ -579,6 +618,27 @@ export const vi: TranslationKeys = { name: "Split CSV", description: "Split a CSV into smaller files by row count", }, + "merge-csvs": { + name: "Merge CSVs", + description: "Combine multiple CSV or TSV files with matching columns into one", + }, + "yaml-json": { + name: "YAML / JSON", + description: "Convert between YAML and JSON, both directions", + }, + "xml-to-csv": { + name: "XML to CSV", + description: "Extract repeating elements from XML into a CSV table", + }, + "create-zip": { + name: "Create ZIP", + description: "Bundle multiple files into a single ZIP archive", + }, + "extract-zip": { + name: "Extract ZIP", + description: + "Safely extract files from a ZIP archive with bomb protection. Single-file archives return the file directly.", + }, pipeline: { name: "Trình xây dựng Pipeline", description: "Kết nối nhiều công cụ thành một quy trình làm việc", @@ -1917,6 +1977,35 @@ export const vi: TranslationKeys = { submitBatch: "Rotate ({count} files)", progressLabel: "Rotating", }, + "convert-document": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-presentation": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-spreadsheet": { + format: "Output format", + hint: "Multi-sheet workbooks export the first sheet to CSV.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "excel-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "powerpoint-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "word-to-pdf": { submit: "Convert to PDF", submitBatch: "Convert ({count} files)", @@ -2051,12 +2140,35 @@ export const vi: TranslationKeys = { submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "markdown-to-docx": { + submit: "Convert to DOCX", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "markdown-to-html": { + hint: "Remote images in the source are left as-is in the output.", + submit: "Convert to HTML", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "markdown-to-pdf": { hint: "Remote resources are disabled for privacy.", submit: "Convert to PDF", submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "epub-convert": { + format: "Output format", + hint: "Remote resources inside the book are not fetched.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "to-epub": { + submit: "Convert to EPUB", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "csv-excel": { sheet: "Worksheet number", submit: "Convert", @@ -2082,6 +2194,31 @@ export const vi: TranslationKeys = { submitBatch: "Split ({count} files)", progressLabel: "Splitting", }, + "merge-csvs": { + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "yaml-json": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "xml-to-csv": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "create-zip": { + submit: "Create ZIP", + submitBatch: "Create ZIP ({count} files)", + progressLabel: "Zipping", + }, + "extract-zip": { + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, }, urlImport: { title: "Nhập từ URL", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 9dbee9bd..46820323 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -423,6 +423,23 @@ export const zhCN: TranslationKeys = { name: "Rotate PDF", description: "Rotate pages in a PDF", }, + "convert-document": { + name: "Convert Document", + description: "Convert between Word, OpenDocument, RTF, and plain text formats", + }, + "convert-presentation": { + name: "Convert Presentation", + description: "Convert between PowerPoint and OpenDocument presentation formats", + }, + "convert-spreadsheet": { + name: "Convert Spreadsheet", + description: + "Convert between Excel, OpenDocument, and CSV formats. Multi-sheet workbooks export the first sheet to CSV.", + }, + "excel-to-pdf": { + name: "Excel to PDF", + description: "Convert spreadsheets to PDF. Wide sheets may paginate across multiple pages.", + }, "word-to-pdf": { name: "Word to PDF", description: "Convert Word documents to PDF", @@ -503,15 +520,37 @@ export const zhCN: TranslationKeys = { name: "PDF Metadata", description: "Read and write PDF document metadata", }, + "powerpoint-to-pdf": { + name: "PowerPoint to PDF", + description: "Convert presentations to PDF", + }, "html-to-pdf": { name: "HTML to PDF", description: "Convert an HTML file to PDF. Remote resources are disabled for privacy.", }, + "markdown-to-docx": { + name: "Markdown to Word", + description: "Convert a Markdown file to a Word document (DOCX)", + }, + "markdown-to-html": { + name: "Markdown to HTML", + description: + "Convert a Markdown file to a standalone HTML page. Remote images in the source are left as-is in the output.", + }, "markdown-to-pdf": { name: "Markdown to PDF", description: "Convert a Markdown file to a styled PDF. Remote resources are disabled for privacy.", }, + "epub-convert": { + name: "Convert EPUB", + description: + "Convert an EPUB to PDF, DOCX, HTML, or Markdown. Remote resources inside the book are not fetched.", + }, + "to-epub": { + name: "Convert to EPUB", + description: "Convert Word, Markdown, HTML, or plain text files to EPUB", + }, "csv-excel": { name: "CSV to Excel", description: "Convert between CSV and Excel (XLSX), both directions", @@ -528,6 +567,27 @@ export const zhCN: TranslationKeys = { name: "Split CSV", description: "Split a CSV into smaller files by row count", }, + "merge-csvs": { + name: "Merge CSVs", + description: "Combine multiple CSV or TSV files with matching columns into one", + }, + "yaml-json": { + name: "YAML / JSON", + description: "Convert between YAML and JSON, both directions", + }, + "xml-to-csv": { + name: "XML to CSV", + description: "Extract repeating elements from XML into a CSV table", + }, + "create-zip": { + name: "Create ZIP", + description: "Bundle multiple files into a single ZIP archive", + }, + "extract-zip": { + name: "Extract ZIP", + description: + "Safely extract files from a ZIP archive with bomb protection. Single-file archives return the file directly.", + }, pipeline: { name: "Pipeline 构建器", description: "将多个工具串联为工作流" }, processing: { canceled: "Processing canceled" }, mediaPlayer: { @@ -1852,6 +1912,35 @@ export const zhCN: TranslationKeys = { submitBatch: "Rotate ({count} files)", progressLabel: "Rotating", }, + "convert-document": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-presentation": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-spreadsheet": { + format: "Output format", + hint: "Multi-sheet workbooks export the first sheet to CSV.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "excel-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "powerpoint-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "word-to-pdf": { submit: "Convert to PDF", submitBatch: "Convert ({count} files)", @@ -1986,12 +2075,35 @@ export const zhCN: TranslationKeys = { submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "markdown-to-docx": { + submit: "Convert to DOCX", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "markdown-to-html": { + hint: "Remote images in the source are left as-is in the output.", + submit: "Convert to HTML", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "markdown-to-pdf": { hint: "Remote resources are disabled for privacy.", submit: "Convert to PDF", submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "epub-convert": { + format: "Output format", + hint: "Remote resources inside the book are not fetched.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "to-epub": { + submit: "Convert to EPUB", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "csv-excel": { sheet: "Worksheet number", submit: "Convert", @@ -2017,6 +2129,31 @@ export const zhCN: TranslationKeys = { submitBatch: "Split ({count} files)", progressLabel: "Splitting", }, + "merge-csvs": { + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "yaml-json": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "xml-to-csv": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "create-zip": { + submit: "Create ZIP", + submitBatch: "Create ZIP ({count} files)", + progressLabel: "Zipping", + }, + "extract-zip": { + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, }, urlImport: { title: "从 URL 导入", diff --git a/packages/shared/src/i18n/zh-TW.ts b/packages/shared/src/i18n/zh-TW.ts index 07b6d89b..c3274a5e 100644 --- a/packages/shared/src/i18n/zh-TW.ts +++ b/packages/shared/src/i18n/zh-TW.ts @@ -422,6 +422,23 @@ export const zhTW: TranslationKeys = { name: "Rotate PDF", description: "Rotate pages in a PDF", }, + "convert-document": { + name: "Convert Document", + description: "Convert between Word, OpenDocument, RTF, and plain text formats", + }, + "convert-presentation": { + name: "Convert Presentation", + description: "Convert between PowerPoint and OpenDocument presentation formats", + }, + "convert-spreadsheet": { + name: "Convert Spreadsheet", + description: + "Convert between Excel, OpenDocument, and CSV formats. Multi-sheet workbooks export the first sheet to CSV.", + }, + "excel-to-pdf": { + name: "Excel to PDF", + description: "Convert spreadsheets to PDF. Wide sheets may paginate across multiple pages.", + }, "word-to-pdf": { name: "Word to PDF", description: "Convert Word documents to PDF", @@ -502,15 +519,37 @@ export const zhTW: TranslationKeys = { name: "PDF Metadata", description: "Read and write PDF document metadata", }, + "powerpoint-to-pdf": { + name: "PowerPoint to PDF", + description: "Convert presentations to PDF", + }, "html-to-pdf": { name: "HTML to PDF", description: "Convert an HTML file to PDF. Remote resources are disabled for privacy.", }, + "markdown-to-docx": { + name: "Markdown to Word", + description: "Convert a Markdown file to a Word document (DOCX)", + }, + "markdown-to-html": { + name: "Markdown to HTML", + description: + "Convert a Markdown file to a standalone HTML page. Remote images in the source are left as-is in the output.", + }, "markdown-to-pdf": { name: "Markdown to PDF", description: "Convert a Markdown file to a styled PDF. Remote resources are disabled for privacy.", }, + "epub-convert": { + name: "Convert EPUB", + description: + "Convert an EPUB to PDF, DOCX, HTML, or Markdown. Remote resources inside the book are not fetched.", + }, + "to-epub": { + name: "Convert to EPUB", + description: "Convert Word, Markdown, HTML, or plain text files to EPUB", + }, "csv-excel": { name: "CSV to Excel", description: "Convert between CSV and Excel (XLSX), both directions", @@ -527,6 +566,27 @@ export const zhTW: TranslationKeys = { name: "Split CSV", description: "Split a CSV into smaller files by row count", }, + "merge-csvs": { + name: "Merge CSVs", + description: "Combine multiple CSV or TSV files with matching columns into one", + }, + "yaml-json": { + name: "YAML / JSON", + description: "Convert between YAML and JSON, both directions", + }, + "xml-to-csv": { + name: "XML to CSV", + description: "Extract repeating elements from XML into a CSV table", + }, + "create-zip": { + name: "Create ZIP", + description: "Bundle multiple files into a single ZIP archive", + }, + "extract-zip": { + name: "Extract ZIP", + description: + "Safely extract files from a ZIP archive with bomb protection. Single-file archives return the file directly.", + }, pipeline: { name: "Pipeline建構器", description: "將多個工具串聯為工作流程" }, processing: { canceled: "Processing canceled" }, mediaPlayer: { @@ -1850,6 +1910,35 @@ export const zhTW: TranslationKeys = { submitBatch: "Rotate ({count} files)", progressLabel: "Rotating", }, + "convert-document": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-presentation": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "convert-spreadsheet": { + format: "Output format", + hint: "Multi-sheet workbooks export the first sheet to CSV.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "excel-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "powerpoint-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "word-to-pdf": { submit: "Convert to PDF", submitBatch: "Convert ({count} files)", @@ -1984,12 +2073,35 @@ export const zhTW: TranslationKeys = { submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "markdown-to-docx": { + submit: "Convert to DOCX", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "markdown-to-html": { + hint: "Remote images in the source are left as-is in the output.", + submit: "Convert to HTML", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "markdown-to-pdf": { hint: "Remote resources are disabled for privacy.", submit: "Convert to PDF", submitBatch: "Convert to PDF ({count} files)", progressLabel: "Converting", }, + "epub-convert": { + format: "Output format", + hint: "Remote resources inside the book are not fetched.", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "to-epub": { + submit: "Convert to EPUB", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, "csv-excel": { sheet: "Worksheet number", submit: "Convert", @@ -2015,6 +2127,31 @@ export const zhTW: TranslationKeys = { submitBatch: "Split ({count} files)", progressLabel: "Splitting", }, + "merge-csvs": { + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "yaml-json": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "xml-to-csv": { + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "create-zip": { + submit: "Create ZIP", + submitBatch: "Create ZIP ({count} files)", + progressLabel: "Zipping", + }, + "extract-zip": { + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, }, urlImport: { title: "從URL匯入", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee9b3bdf..b7b37cee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -245,6 +245,9 @@ importers: tsx: specifier: ^4.22.4 version: 4.22.4 + yauzl: + specifier: ^3.4.0 + version: 3.4.0 zod: specifier: ^3.24.0 version: 3.25.76 @@ -285,6 +288,9 @@ importers: '@types/tar': specifier: ^7.0.87 version: 7.0.87 + '@types/yauzl': + specifier: ^3.3.0 + version: 3.3.0 drizzle-kit: specifier: ^0.31.0 version: 0.31.10 @@ -3432,6 +3438,9 @@ packages: '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@types/yauzl@3.3.0': + resolution: {integrity: sha512-7YJZZveDGYqCMt0PrN0sjZj9gT07xLqnHBimKwhWYTJ4lh5MxDGmaCaN4+6x1TOPt+dk3Ge8w9cO3uJXoy/qrA==} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} deprecated: Potential CWE-502 - Update to 1.3.1 or higher @@ -6031,6 +6040,9 @@ packages: resolution: {integrity: sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==} engines: {node: '>=8'} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} @@ -7422,6 +7434,10 @@ packages: resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yauzl@3.4.0: + resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} + engines: {node: '>=12'} + yoctocolors@2.1.2: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} @@ -10545,6 +10561,10 @@ snapshots: '@types/web-bluetooth@0.0.21': {} + '@types/yauzl@3.3.0': + dependencies: + '@types/node': 22.19.19 + '@ungap/structured-clone@1.3.0': {} '@use-gesture/core@10.3.1': {} @@ -13184,6 +13204,8 @@ snapshots: peek-readable@4.1.0: {} + pend@1.2.0: {} + perfect-debounce@1.0.0: {} pg-cloudflare@1.4.0: @@ -14788,6 +14810,10 @@ snapshots: y18n: 5.0.8 yargs-parser: 22.0.0 + yauzl@3.4.0: + dependencies: + pend: 1.2.0 + yoctocolors@2.1.2: {} zip-stream@4.1.1: diff --git a/scripts/dev/make-tiny-pptx.mjs b/scripts/dev/make-tiny-pptx.mjs new file mode 100644 index 00000000..ba959563 --- /dev/null +++ b/scripts/dev/make-tiny-pptx.mjs @@ -0,0 +1,291 @@ +#!/usr/bin/env node + +// make-tiny-pptx.mjs +// +// Builds a minimal ECMA-376 (OOXML) presentation skeleton and writes it to +// tests/fixtures/documents/tiny.pptx using adm-zip (root devDep). +// +// ACCEPTANCE (2026-06-12, snapotter/snapotter:latest + libreoffice-impress): +// soffice --headless --norestore --nolockcheck --nodefault \ +// --convert-to pdf --outdir /tmp /tmp/tiny.pptx +// Result: /tmp/tiny.pdf produced, 8454 bytes. Impress opens the skeleton +// and renders the single-slide text box via impress_pdf_Export filter. +// NOTE: libreoffice-impress must be added to the Dockerfile (wave 3b image +// only had libreoffice-writer + libreoffice-calc). + +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import AdmZip from "adm-zip"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const outPath = join(__dirname, "..", "..", "tests", "fixtures", "documents", "tiny.pptx"); + +const zip = new AdmZip(); + +// [Content_Types].xml +zip.addFile( + "[Content_Types].xml", + Buffer.from(` + + + + + + + + +`), +); + +// _rels/.rels +zip.addFile( + "_rels/.rels", + Buffer.from(` + + +`), +); + +// ppt/presentation.xml +zip.addFile( + "ppt/presentation.xml", + Buffer.from(` + + + + + + + + + +`), +); + +// ppt/_rels/presentation.xml.rels +zip.addFile( + "ppt/_rels/presentation.xml.rels", + Buffer.from(` + + + + +`), +); + +// ppt/slides/slide1.xml +zip.addFile( + "ppt/slides/slide1.xml", + Buffer.from(` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SnapOtter pptx fixture + + + + + + +`), +); + +// ppt/slides/_rels/slide1.xml.rels +zip.addFile( + "ppt/slides/_rels/slide1.xml.rels", + Buffer.from(` + + +`), +); + +// ppt/slideLayouts/slideLayout1.xml +zip.addFile( + "ppt/slideLayouts/slideLayout1.xml", + Buffer.from(` + + + + + + + + + + + + + + + + + + +`), +); + +// ppt/slideLayouts/_rels/slideLayout1.xml.rels +zip.addFile( + "ppt/slideLayouts/_rels/slideLayout1.xml.rels", + Buffer.from(` + + +`), +); + +// ppt/slideMasters/slideMaster1.xml +zip.addFile( + "ppt/slideMasters/slideMaster1.xml", + Buffer.from(` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`), +); + +// ppt/slideMasters/_rels/slideMaster1.xml.rels +zip.addFile( + "ppt/slideMasters/_rels/slideMaster1.xml.rels", + Buffer.from(` + + + +`), +); + +// ppt/theme/theme1.xml +zip.addFile( + "ppt/theme/theme1.xml", + Buffer.from(` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`), +); + +zip.writeZip(outPath); +console.log(`Wrote ${outPath}`); diff --git a/tests/fixtures/data/tiny-a.csv b/tests/fixtures/data/tiny-a.csv new file mode 100644 index 00000000..6733a3bf --- /dev/null +++ b/tests/fixtures/data/tiny-a.csv @@ -0,0 +1,2 @@ +id,name +1,alpha diff --git a/tests/fixtures/data/tiny-b.csv b/tests/fixtures/data/tiny-b.csv new file mode 100644 index 00000000..3fdbcb66 --- /dev/null +++ b/tests/fixtures/data/tiny-b.csv @@ -0,0 +1,2 @@ +id,name +2,beta diff --git a/tests/fixtures/data/tiny.tsv b/tests/fixtures/data/tiny.tsv new file mode 100644 index 00000000..cc1d5255 --- /dev/null +++ b/tests/fixtures/data/tiny.tsv @@ -0,0 +1,3 @@ +id name +1 alpha +2 beta diff --git a/tests/fixtures/data/tiny.yaml b/tests/fixtures/data/tiny.yaml new file mode 100644 index 00000000..d88a33a4 --- /dev/null +++ b/tests/fixtures/data/tiny.yaml @@ -0,0 +1,6 @@ +name: SnapOtter +items: + - id: 1 + label: alpha + - id: 2 + label: beta diff --git a/tests/fixtures/documents/tiny.odt b/tests/fixtures/documents/tiny.odt new file mode 100644 index 00000000..477dd78b Binary files /dev/null and b/tests/fixtures/documents/tiny.odt differ diff --git a/tests/fixtures/documents/tiny.pptx b/tests/fixtures/documents/tiny.pptx new file mode 100644 index 00000000..9289b365 Binary files /dev/null and b/tests/fixtures/documents/tiny.pptx differ diff --git a/tests/fixtures/documents/tiny.rtf b/tests/fixtures/documents/tiny.rtf new file mode 100644 index 00000000..37a4c157 --- /dev/null +++ b/tests/fixtures/documents/tiny.rtf @@ -0,0 +1,17 @@ +{\rtf1\ansi\deff4\adeflang1025 +{\fonttbl{\f0\froman\fprq2\fcharset0 Times New Roman;}{\f1\froman\fprq2\fcharset2 Symbol;}{\f2\fswiss\fprq2\fcharset0 Arial;}{\f3\froman\fprq2\fcharset0 Liberation Serif{\*\falt Times New Roman};}{\f4\froman\fprq2\fcharset0 Calibri;}{\f5\fswiss\fprq2\fcharset0 Liberation Sans{\*\falt Arial};}{\f6\fnil\fprq2\fcharset0 WenQuanYi Zen Hei;}{\f7\fswiss\fprq0\fcharset128 FreeSans;}{\f8\fnil\fprq2\fcharset0 FreeSans;}} +{\colortbl;\red0\green0\blue0;\red0\green0\blue255;\red0\green255\blue255;\red0\green255\blue0;\red255\green0\blue255;\red255\green0\blue0;\red255\green255\blue0;\red255\green255\blue255;\red0\green0\blue128;\red0\green128\blue128;\red0\green128\blue0;\red128\green0\blue128;\red128\green0\blue0;\red128\green128\blue0;\red128\green128\blue128;\red192\green192\blue192;} +{\stylesheet{\s0\snext0\rtlch\af8\afs24\alang1081 \ltrch\lang1033\langfe2052\hich\af4\loch\nowidctlpar\hyphpar0\ltrpar\cf0\f4\fs22\lang1033\kerning1\dbch\af6\langfe2052 Normal;} +{\s15\sbasedon0\snext16\rtlch\af8\afs28 \ltrch\hich\af5\loch\sb240\sa120\keepn\f5\fs28\dbch\af6 Heading;} +{\s16\sbasedon0\snext16\loch\sl276\slmult1\sb0\sa140 Text Body;} +{\s17\sbasedon16\snext17\rtlch\af7 \ltrch\loch\sl276\slmult1\sb0\sa140 List;} +{\s18\sbasedon0\snext18\rtlch\af7\afs24\ai \ltrch\loch\sb120\sa120\noline\fs24\i Caption;} +{\s19\sbasedon0\snext19\rtlch\af7 \ltrch\loch\noline Index;} +}{\*\generator LibreOffice/7.4.7.2$Linux_AARCH64 LibreOffice_project/40$Build-2}{\info{\creatim\yr0\mo0\dy0\hr0\min0}{\revtim\yr0\mo0\dy0\hr0\min0}{\printim\yr0\mo0\dy0\hr0\min0}}{\*\userprops}\deftab709 +\hyphauto1\viewscale100 +{\*\pgdsctbl +{\pgdsc0\pgdscuse451\pgwsxn12240\pghsxn15840\marglsxn1440\margrsxn1440\margtsxn1440\margbsxn1440\pgdscnxt0 Default Page Style;}} +\formshade\paperh15840\paperw12240\margl1440\margr1440\margt1440\margb1440\sectd\sbknone\pgndec\sftnnar\saftnnrlc\sectunlocked1\pgwsxn12240\pghsxn15840\marglsxn1440\margrsxn1440\margtsxn1440\margbsxn1440\ftnbj\ftnstart1\ftnrstcont\ftnnar\aenddoc\aftnrstcont\aftnstart1\aftnnrlc +{\*\ftnsep\chftnsep}\pgndec\pard\plain \s0\rtlch\af8\afs24\alang1081 \ltrch\lang1033\langfe2052\hich\af4\loch\nowidctlpar\hyphpar0\ltrpar\cf0\f4\fs22\lang1033\kerning1\dbch\af6\langfe2052\ql\ltrpar{\loch +SnapOtter test document} +\par } \ No newline at end of file diff --git a/tests/helpers/tool-default-settings.ts b/tests/helpers/tool-default-settings.ts index edcaf896..307fa300 100644 --- a/tests/helpers/tool-default-settings.ts +++ b/tests/helpers/tool-default-settings.ts @@ -30,6 +30,10 @@ export const TOOL_SETTINGS_OVERRIDES: Record = { "resize-video": { preset: "720p" }, "watermark-video": { text: "CONFIDENTIAL" }, "audio-channels": { mode: "mono-to-stereo" }, + "convert-document": { format: "odt" }, + "epub-convert": { format: "html" }, + "convert-presentation": { format: "odp" }, + "convert-spreadsheet": { format: "ods" }, }; export function defaultSettingsFor(toolId: string): unknown { diff --git a/tests/integration/convert-document.test.ts b/tests/integration/convert-document.test.ts new file mode 100644 index 00000000..4b5ae51a --- /dev/null +++ b/tests/integration/convert-document.test.ts @@ -0,0 +1,108 @@ +// convert-document integration suite. +// Requires LibreOffice (soffice). Skips locally (soffice absent on dev Macs); +// the Docker compose smoke is the real proof that this tool works end to end +// against the containerised LibreOffice install. + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { sofficeAvailable } from "@snapotter/doc-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const DOCX = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.docx")); +const ODT = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.odt")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(filename: string, content: Buffer, settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename, contentType: "application/octet-stream", content }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/convert-document", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +async function pollJob(jobId: string) { + const { db, schema } = await import("../../apps/api/src/db/index.js"); + const { eq } = await import("drizzle-orm"); + let row: { status: string; outputRefs: unknown; error: unknown } | undefined; + for (let i = 0; i < 120; i++) { + [row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); + if (row && ["completed", "failed", "canceled"].includes(row.status)) break; + await new Promise((r) => setTimeout(r, 500)); + } + return row; +} + +describe.skipIf(!sofficeAvailable())("convert-document (requires soffice)", () => { + it("converts docx to odt (PK magic)", async () => { + const res = await runTool("tiny.docx", DOCX, { format: "odt" }); + expect(res.statusCode).toBe(202); + const { jobId } = JSON.parse(res.body); + const row = await pollJob(jobId); + expect(row?.status).toBe("completed"); + const outName = (row?.outputRefs as string[])[0].split("/").pop() as string; + const dl = await testApp.app.inject({ + method: "GET", + url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`, + }); + expect(dl.statusCode).toBe(200); + // ODT is a ZIP (PK magic) + expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK"); + }, 90_000); + + it("converts odt to docx (PK magic)", async () => { + const res = await runTool("tiny.odt", ODT, { format: "docx" }); + expect(res.statusCode).toBe(202); + const { jobId } = JSON.parse(res.body); + const row = await pollJob(jobId); + expect(row?.status).toBe("completed"); + const outName = (row?.outputRefs as string[])[0].split("/").pop() as string; + const dl = await testApp.app.inject({ + method: "GET", + url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`, + }); + expect(dl.statusCode).toBe(200); + // DOCX is a ZIP (PK magic) + expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK"); + }, 90_000); + + it("rejects same-format conversion (docx to docx)", async () => { + const res = await runTool("tiny.docx", DOCX, { format: "docx" }); + expect(res.statusCode).toBe(202); + const { jobId } = JSON.parse(res.body); + const row = await pollJob(jobId); + expect(row?.status).toBe("failed"); + const error = row?.error as { message: string } | null; + expect(error?.message).toMatch(/already in that format/i); + }, 90_000); +}); + +it("rejects missing format with 400", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.docx", contentType: "application/octet-stream", content: DOCX }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/convert-document", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + expect(res.statusCode).toBe(400); +}); diff --git a/tests/integration/convert-presentation.test.ts b/tests/integration/convert-presentation.test.ts new file mode 100644 index 00000000..fe7b7399 --- /dev/null +++ b/tests/integration/convert-presentation.test.ts @@ -0,0 +1,81 @@ +// convert-presentation integration suite. +// Requires LibreOffice (soffice). Skips locally (soffice absent on dev Macs); +// the Docker compose smoke is the real proof that this tool works end to end +// against the containerised LibreOffice install. + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { sofficeAvailable } from "@snapotter/doc-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const PPTX = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.pptx")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(filename: string, content: Buffer, settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename, contentType: "application/octet-stream", content }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/convert-presentation", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +async function pollJob(jobId: string) { + const { db, schema } = await import("../../apps/api/src/db/index.js"); + const { eq } = await import("drizzle-orm"); + let row: { status: string; outputRefs: unknown; error: unknown } | undefined; + for (let i = 0; i < 120; i++) { + [row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); + if (row && ["completed", "failed", "canceled"].includes(row.status)) break; + await new Promise((r) => setTimeout(r, 500)); + } + return row; +} + +describe.skipIf(!sofficeAvailable())("convert-presentation (requires soffice)", () => { + it("converts pptx to odp (PK magic)", async () => { + const res = await runTool("tiny.pptx", PPTX, { format: "odp" }); + expect(res.statusCode).toBe(202); + const { jobId } = JSON.parse(res.body); + const row = await pollJob(jobId); + expect(row?.status).toBe("completed"); + const outName = (row?.outputRefs as string[])[0].split("/").pop() as string; + const dl = await testApp.app.inject({ + method: "GET", + url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`, + }); + expect(dl.statusCode).toBe(200); + // ODP is a ZIP (PK magic) + expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK"); + }, 90_000); +}); + +it("rejects missing format with 400", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.pptx", contentType: "application/octet-stream", content: PPTX }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/convert-presentation", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + expect(res.statusCode).toBe(400); +}); diff --git a/tests/integration/convert-spreadsheet.test.ts b/tests/integration/convert-spreadsheet.test.ts new file mode 100644 index 00000000..2b0373ba --- /dev/null +++ b/tests/integration/convert-spreadsheet.test.ts @@ -0,0 +1,98 @@ +// convert-spreadsheet integration suite. +// Requires LibreOffice (soffice). Skips locally (soffice absent on dev Macs); +// the Docker compose smoke is the real proof that this tool works end to end +// against the containerised LibreOffice install. + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { sofficeAvailable } from "@snapotter/doc-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const XLSX = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.xlsx")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(filename: string, content: Buffer, settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename, contentType: "application/octet-stream", content }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/convert-spreadsheet", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +async function pollJob(jobId: string) { + const { db, schema } = await import("../../apps/api/src/db/index.js"); + const { eq } = await import("drizzle-orm"); + let row: { status: string; outputRefs: unknown; error: unknown } | undefined; + for (let i = 0; i < 120; i++) { + [row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); + if (row && ["completed", "failed", "canceled"].includes(row.status)) break; + await new Promise((r) => setTimeout(r, 500)); + } + return row; +} + +describe.skipIf(!sofficeAvailable())("convert-spreadsheet (requires soffice)", () => { + it("converts xlsx to ods (PK magic)", async () => { + const res = await runTool("tiny.xlsx", XLSX, { format: "ods" }); + expect(res.statusCode).toBe(202); + const { jobId } = JSON.parse(res.body); + const row = await pollJob(jobId); + expect(row?.status).toBe("completed"); + const outName = (row?.outputRefs as string[])[0].split("/").pop() as string; + const dl = await testApp.app.inject({ + method: "GET", + url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`, + }); + expect(dl.statusCode).toBe(200); + // ODS is a ZIP (PK magic) + expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK"); + }, 90_000); + + it("converts csv to xlsx (PK magic)", async () => { + const csvContent = Buffer.from("id,name\n1,alpha\n"); + const res = await runTool("data.csv", csvContent, { format: "xlsx" }); + expect(res.statusCode).toBe(202); + const { jobId } = JSON.parse(res.body); + const row = await pollJob(jobId); + expect(row?.status).toBe("completed"); + const outName = (row?.outputRefs as string[])[0].split("/").pop() as string; + const dl = await testApp.app.inject({ + method: "GET", + url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`, + }); + expect(dl.statusCode).toBe(200); + // XLSX is a ZIP (PK magic) + expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK"); + }, 90_000); +}); + +it("rejects missing format with 400", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.xlsx", contentType: "application/octet-stream", content: XLSX }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/convert-spreadsheet", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + expect(res.statusCode).toBe(400); +}); diff --git a/tests/integration/create-zip.test.ts b/tests/integration/create-zip.test.ts new file mode 100644 index 00000000..ccaf0c7c --- /dev/null +++ b/tests/integration/create-zip.test.ts @@ -0,0 +1,66 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const CSV_A = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny-a.csv")); +const CSV_B = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny-b.csv")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("create-zip (pure JS, no skipIf)", () => { + it("zips two fixture files into a valid ZIP with PK magic", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny-a.csv", contentType: "text/csv", content: CSV_A }, + { name: "file", filename: "tiny-b.csv", contentType: "text/csv", content: CSV_B }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/create-zip", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + + // PK magic bytes + expect(dl.rawPayload[0]).toBe(0x50); + expect(dl.rawPayload[1]).toBe(0x4b); + + // Non-trivial size (both fixture files + zip overhead) + expect(dl.rawPayload.length).toBeGreaterThan(CSV_A.length + CSV_B.length - 50); + }, 30_000); + + it("rejects a single file with 422 'at least two'", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny-a.csv", contentType: "text/csv", content: CSV_A }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/create-zip", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(422); + const parsed = JSON.parse(res.body); + expect(parsed.details).toMatch(/at least two/i); + }, 30_000); +}); diff --git a/tests/integration/csv-excel.test.ts b/tests/integration/csv-excel.test.ts index e5fa8cec..49094ebe 100644 --- a/tests/integration/csv-excel.test.ts +++ b/tests/integration/csv-excel.test.ts @@ -5,6 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; const CSV = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.csv")); +const TSV = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.tsv")); const XLSX_FIXTURE = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.xlsx")); let testApp: TestApp; @@ -61,6 +62,23 @@ describe("csv-excel (pure JS, no skipIf)", () => { expect(sstXml).toContain("name"); }, 30_000); + it("converts TSV to XLSX with PK magic", async () => { + const res = await runTool("tiny.tsv", TSV); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ + method: "GET", + url: envelope.downloadUrl, + }); + expect(dl.statusCode).toBe(200); + + // XLSX files start with PK zip magic + expect(dl.rawPayload[0]).toBe(0x50); + expect(dl.rawPayload[1]).toBe(0x4b); + }, 30_000); + it("converts XLSX to CSV containing the fixture content", async () => { // Use the committed tiny.xlsx from tests/fixtures/documents/ // (Sheet1 with "SnapOtter" in A1) diff --git a/tests/integration/csv-json.test.ts b/tests/integration/csv-json.test.ts index efe088ba..bf85700b 100644 --- a/tests/integration/csv-json.test.ts +++ b/tests/integration/csv-json.test.ts @@ -4,6 +4,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; const CSV = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.csv")); +const TSV = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.tsv")); const JSON_FIXTURE = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.json")); let testApp: TestApp; @@ -68,6 +69,26 @@ describe("csv-json (pure JS, no skipIf)", () => { expect(csvText).toContain("Ada"); }, 30_000); + it("converts TSV to JSON with the correct keys", async () => { + const res = await runTool("tiny.tsv", TSV, { pretty: true }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ + method: "GET", + url: envelope.downloadUrl, + }); + expect(dl.statusCode).toBe(200); + + const data = JSON.parse(dl.payload); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBe(2); + const first = data[0] as Record; + expect(first.id).toBe("1"); + expect(first.name).toBe("alpha"); + }, 30_000); + it("rejects non-array JSON input for JSON-to-CSV", async () => { const obj = Buffer.from(JSON.stringify({ key: "value" })); const res = await runTool("obj.json", obj); diff --git a/tests/integration/epub-convert.test.ts b/tests/integration/epub-convert.test.ts new file mode 100644 index 00000000..88d1d70a --- /dev/null +++ b/tests/integration/epub-convert.test.ts @@ -0,0 +1,206 @@ +// epub-convert integration suite. +// Requires pandoc (and weasyprint for the pdf case). Skips locally +// (pandoc absent on dev Macs); the Docker compose smoke is the real proof. + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { pandocAvailable } from "@snapotter/doc-engine"; +import AdmZip from "adm-zip"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { pythonWith } from "../helpers/python-gate.js"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const EPUB = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.epub")); + +/** Build a minimal valid epub in memory containing a remote image reference. */ +function buildRemoteRefEpub(): Buffer { + const zip = new AdmZip(); + // mimetype must be first entry, stored (no compression) + zip.addFile("mimetype", Buffer.from("application/epub+zip"), "", 0o644); + zip.addFile( + "META-INF/container.xml", + Buffer.from( + ` + + + + +`, + ), + ); + zip.addFile( + "OEBPS/content.opf", + Buffer.from( + ` + + + urn:uuid:00000000-0000-0000-0000-000000000001 + RemoteRefBook + en + 2024-01-01T00:00:00Z + + + + + +`, + ), + ); + zip.addFile( + "OEBPS/chapter1.xhtml", + Buffer.from( + ` + +RemoteRefBook + +

RemoteRefBook

+

Content for remote-ref SSRF test.

+ remote + +`, + ), + ); + return zip.toBuffer(); +} + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(filename: string, content: Buffer, settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename, contentType: "application/octet-stream", content }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/epub-convert", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!pandocAvailable())("epub-convert (requires pandoc)", () => { + it("converts epub to HTML containing source text", async () => { + const res = await runTool("tiny.epub", EPUB, { format: "html" }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ + method: "GET", + url: envelope.downloadUrl, + }); + expect(dl.statusCode).toBe(200); + expect(dl.payload).toContain("SnapOtter test epub"); + }, 30_000); + + it("converts epub to DOCX with PK magic", async () => { + const res = await runTool("tiny.epub", EPUB, { format: "docx" }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ + method: "GET", + url: envelope.downloadUrl, + }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK"); + }, 30_000); + + it("html output passes remote refs through without fetching (no SSRF)", async () => { + const remoteEpub = buildRemoteRefEpub(); + const res = await runTool("remote.epub", remoteEpub, { format: "html" }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ + method: "GET", + url: envelope.downloadUrl, + }); + expect(dl.statusCode).toBe(200); + // The literal remote URL must still be present (pandoc did not inline it) + expect(dl.payload).toContain("http://127.0.0.1:9/x.png"); + expect(dl.payload).toContain("RemoteRefBook"); + }, 30_000); + + it("converts epub to Markdown containing source text", async () => { + const res = await runTool("tiny.epub", EPUB, { format: "md" }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ + method: "GET", + url: envelope.downloadUrl, + }); + expect(dl.statusCode).toBe(200); + expect(dl.payload.length).toBeGreaterThan(0); + expect(dl.payload).toContain("SnapOtter"); + }, 30_000); +}); + +describe.skipIf(!pandocAvailable() || !pythonWith("weasyprint"))( + "epub-convert pdf (requires pandoc + weasyprint)", + () => { + it("pdf output rejects remote refs in epub content", async () => { + const remoteEpub = buildRemoteRefEpub(); + const res = await runTool("remote.epub", remoteEpub, { format: "pdf" }); + // Long hint: expects 202 with jobId + expect(res.statusCode).toBe(202); + const { jobId } = JSON.parse(res.body); + const { db, schema } = await import("../../apps/api/src/db/index.js"); + const { eq } = await import("drizzle-orm"); + let row: { status: string; errorMessage: string | null } | undefined; + for (let i = 0; i < 120; i++) { + [row] = await db + .select({ status: schema.jobs.status, errorMessage: schema.jobs.errorMessage }) + .from(schema.jobs) + .where(eq(schema.jobs.id, jobId)); + if (row && ["completed", "failed", "canceled"].includes(row.status)) break; + await new Promise((r) => setTimeout(r, 500)); + } + expect(row?.status).toBe("failed"); + expect(row?.errorMessage).toMatch(/remote resources are disabled/i); + }, 90_000); + + it("converts epub to PDF via the weasyprint chain", async () => { + const res = await runTool("tiny.epub", EPUB, { format: "pdf" }); + // Long hint: expects 202 with jobId + expect(res.statusCode).toBe(202); + const { jobId } = JSON.parse(res.body); + const { db, schema } = await import("../../apps/api/src/db/index.js"); + const { eq } = await import("drizzle-orm"); + let row: { status: string; outputRefs: unknown } | undefined; + for (let i = 0; i < 120; i++) { + [row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); + if (row && ["completed", "failed", "canceled"].includes(row.status)) break; + await new Promise((r) => setTimeout(r, 500)); + } + expect(row?.status).toBe("completed"); + const outName = (row?.outputRefs as string[])[0].split("/").pop() as string; + const dl = await testApp.app.inject({ + method: "GET", + url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`, + }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.subarray(0, 5).toString()).toBe("%PDF-"); + }, 90_000); + }, +); + +// Ungated tests: run locally without pandoc +it("rejects missing format with 400", async () => { + const res = await runTool("tiny.epub", EPUB, {}); + expect(res.statusCode).toBe(400); +}); diff --git a/tests/integration/excel-to-pdf.test.ts b/tests/integration/excel-to-pdf.test.ts new file mode 100644 index 00000000..0cc47e8d --- /dev/null +++ b/tests/integration/excel-to-pdf.test.ts @@ -0,0 +1,81 @@ +// excel-to-pdf integration suite. +// Requires LibreOffice (soffice). Skips locally (soffice absent on dev Macs); +// the Docker compose smoke is the real proof that this tool works end to end +// against the containerised LibreOffice install. + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { sofficeAvailable } from "@snapotter/doc-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const XLSX = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.xlsx")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(filename: string, content: Buffer) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename, contentType: "application/octet-stream", content }, + { name: "settings", content: JSON.stringify({}) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/excel-to-pdf", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!sofficeAvailable())("excel-to-pdf (requires soffice)", () => { + it("returns 202 (long hint) and the job completes with a PDF", async () => { + const res = await runTool("tiny.xlsx", XLSX); + expect(res.statusCode).toBe(202); + const { jobId } = JSON.parse(res.body); + // Poll the durable row until terminal (the long hint skips the sync window). + const { db, schema } = await import("../../apps/api/src/db/index.js"); + const { eq } = await import("drizzle-orm"); + let row: { status: string; outputRefs: unknown } | undefined; + for (let i = 0; i < 120; i++) { + [row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); + if (row && ["completed", "failed", "canceled"].includes(row.status)) break; + await new Promise((r) => setTimeout(r, 500)); + } + expect(row?.status).toBe("completed"); + const outName = (row?.outputRefs as string[])[0].split("/").pop() as string; + const dl = await testApp.app.inject({ + method: "GET", + url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`, + }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.subarray(0, 5).toString()).toBe("%PDF-"); + }, 90_000); +}); + +it("rejects unsupported file types with 415", async () => { + const { body, contentType } = createMultipartPayload([ + { + name: "file", + filename: "readme.txt", + contentType: "text/plain", + content: Buffer.from("hello"), + }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/excel-to-pdf", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + expect(res.statusCode).toBe(415); +}); diff --git a/tests/integration/extract-zip.test.ts b/tests/integration/extract-zip.test.ts new file mode 100644 index 00000000..ee7a4dd2 --- /dev/null +++ b/tests/integration/extract-zip.test.ts @@ -0,0 +1,141 @@ +import { createWriteStream, readFileSync } from "node:fs"; +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import AdmZip from "adm-zip"; +import archiver from "archiver"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const CSV_A = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny-a.csv")); +const CSV_B = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny-b.csv")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +/** Build a zip buffer in memory using archiver. */ +async function buildZipBuffer(entries: Array<{ name: string; content: Buffer }>): Promise { + const tmpDir = join(tmpdir(), `extract-zip-test-${Date.now()}`); + await mkdir(tmpDir, { recursive: true }); + const zipPath = join(tmpDir, "test.zip"); + + await new Promise((resolve, reject) => { + const output = createWriteStream(zipPath); + const archive = archiver("zip", { zlib: { level: 1 } }); + output.on("close", () => resolve()); + archive.on("error", (err: Error) => reject(err)); + archive.pipe(output); + for (const entry of entries) { + archive.append(entry.content, { name: entry.name }); + } + void archive.finalize(); + }); + + const buf = readFileSync(zipPath); + await rm(tmpDir, { recursive: true, force: true }); + return buf; +} + +async function runExtract(filename: string, content: Buffer) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename, contentType: "application/zip", content }, + { name: "settings", content: JSON.stringify({}) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/extract-zip", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe("extract-zip (pure JS, no skipIf)", () => { + it("extracts a two-entry zip into _extracted.zip with PK magic and resultPayload", async () => { + const zipBuf = await buildZipBuffer([ + { name: "tiny-a.csv", content: CSV_A }, + { name: "tiny-b.csv", content: CSV_B }, + ]); + + const res = await runExtract("test.zip", zipBuf); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + // resultPayload is spread into the envelope + expect(envelope.entries).toBeDefined(); + expect(envelope.entries).toHaveLength(2); + + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + + // Output should be a zip (_extracted.zip) + expect(dl.rawPayload[0]).toBe(0x50); + expect(dl.rawPayload[1]).toBe(0x4b); + }, 30_000); + + it("extracts a single-entry zip returning the bare file content", async () => { + const content = Buffer.from("hello world from extract-zip test"); + const zipBuf = await buildZipBuffer([{ name: "readme.txt", content }]); + + const res = await runExtract("single.zip", zipBuf); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + // resultPayload is spread into the envelope + expect(envelope.entries).toBeDefined(); + expect(envelope.entries).toHaveLength(1); + expect(envelope.entries[0].name).toBe("readme.txt"); + + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + + // Content should match the original + expect(dl.payload).toBe("hello world from extract-zip test"); + }, 30_000); + + it("rejects a zip with path traversal (../evil.txt) with 422", async () => { + // Most zip libraries sanitize entry names, so we binary-patch + // a placeholder to inject "../evil.txt" into the raw zip bytes. + const zip = new AdmZip(); + // Placeholder same length as "../evil.txt" (11 chars) + zip.addFile("XXXXXXXXXXX", Buffer.from("evil")); + const zipBuf = Buffer.from(zip.toBuffer()); + + const placeholder = Buffer.from("XXXXXXXXXXX"); + const replacement = Buffer.from("../evil.txt"); + let offset = zipBuf.indexOf(placeholder); + while (offset !== -1) { + replacement.copy(zipBuf, offset); + offset = zipBuf.indexOf(placeholder, offset + 1); + } + + const res = await runExtract("traversal.zip", zipBuf); + expect(res.statusCode).toBe(422); + const parsed = JSON.parse(res.body); + // yauzl itself rejects "../" paths with "invalid relative path" (defense in depth); + // our guard also catches them if yauzl's validation is bypassed + expect(parsed.details).toMatch(/unsafe entry path|invalid relative path/i); + }, 30_000); + + it("rejects a high-ratio zip bomb with 422", async () => { + // Create a 60 MiB zero buffer - compresses to a very small zip + const bomb = Buffer.alloc(60 * 1024 * 1024, 0); + const zipBuf = await buildZipBuffer([{ name: "bomb.bin", content: bomb }]); + + // The zip itself should be very small due to compression + expect(zipBuf.length).toBeLessThan(1 * 1024 * 1024); + + const res = await runExtract("bomb.zip", zipBuf); + expect(res.statusCode).toBe(422); + const parsed = JSON.parse(res.body); + expect(parsed.details).toMatch(/suspicious compression/i); + }, 60_000); +}); diff --git a/tests/integration/markdown-to-docx.test.ts b/tests/integration/markdown-to-docx.test.ts new file mode 100644 index 00000000..96dd3c87 --- /dev/null +++ b/tests/integration/markdown-to-docx.test.ts @@ -0,0 +1,73 @@ +// markdown-to-docx integration suite. +// Requires pandoc. Skips locally (pandoc absent on dev Macs); +// the Docker compose smoke is the real proof that this tool works end to end +// against the containerised pandoc install. + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { pandocAvailable } from "@snapotter/doc-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MD = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.md")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(filename: string, content: Buffer) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename, contentType: "application/octet-stream", content }, + { name: "settings", content: JSON.stringify({}) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/markdown-to-docx", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!pandocAvailable())("markdown-to-docx (requires pandoc)", () => { + it("converts tiny.md to DOCX with PK magic", async () => { + const res = await runTool("tiny.md", MD); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ + method: "GET", + url: envelope.downloadUrl, + }); + expect(dl.statusCode).toBe(200); + // DOCX is a ZIP archive (PK magic bytes) + expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK"); + }, 30_000); +}); + +it("rejects unsupported file types with 415", async () => { + const { body, contentType } = createMultipartPayload([ + { + name: "file", + filename: "readme.txt", + contentType: "text/plain", + content: Buffer.from("hello"), + }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/markdown-to-docx", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + expect(res.statusCode).toBe(415); +}); diff --git a/tests/integration/markdown-to-html.test.ts b/tests/integration/markdown-to-html.test.ts new file mode 100644 index 00000000..5cf5b183 --- /dev/null +++ b/tests/integration/markdown-to-html.test.ts @@ -0,0 +1,73 @@ +// markdown-to-html integration suite. +// Requires pandoc. Skips locally (pandoc absent on dev Macs); +// the Docker compose smoke is the real proof that this tool works end to end +// against the containerised pandoc install. + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { pandocAvailable } from "@snapotter/doc-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MD = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.md")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(filename: string, content: Buffer) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename, contentType: "application/octet-stream", content }, + { name: "settings", content: JSON.stringify({}) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/markdown-to-html", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!pandocAvailable())("markdown-to-html (requires pandoc)", () => { + it("converts tiny.md to HTML containing source text", async () => { + const res = await runTool("tiny.md", MD); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ + method: "GET", + url: envelope.downloadUrl, + }); + expect(dl.statusCode).toBe(200); + // The fixture contains "SnapOtter Markdown fixture" as a heading + expect(dl.payload).toContain("SnapOtter Markdown fixture"); + }, 30_000); +}); + +it("rejects unsupported file types with 415", async () => { + const { body, contentType } = createMultipartPayload([ + { + name: "file", + filename: "readme.txt", + contentType: "text/plain", + content: Buffer.from("hello"), + }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/markdown-to-html", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + expect(res.statusCode).toBe(415); +}); diff --git a/tests/integration/merge-csvs.test.ts b/tests/integration/merge-csvs.test.ts new file mode 100644 index 00000000..87bdb630 --- /dev/null +++ b/tests/integration/merge-csvs.test.ts @@ -0,0 +1,85 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const CSV_A = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny-a.csv")); +const CSV_B = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny-b.csv")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("merge-csvs (pure JS, no skipIf)", () => { + it("merges tiny-a.csv and tiny-b.csv into one file with both rows", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny-a.csv", contentType: "text/csv", content: CSV_A }, + { name: "file", filename: "tiny-b.csv", contentType: "text/csv", content: CSV_B }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/merge-csvs", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + + const text = dl.payload; + const lines = text.trim().split("\n"); + // One header line + two data rows + expect(lines.length).toBe(3); + expect(text).toContain("alpha"); + expect(text).toContain("beta"); + }, 30_000); + + it("rejects mismatched headers with 422", async () => { + const bad = Buffer.from("x,y\n10,20\n"); + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny-a.csv", contentType: "text/csv", content: CSV_A }, + { name: "file", filename: "bad.csv", contentType: "text/csv", content: bad }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/merge-csvs", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(422); + const parsed = JSON.parse(res.body); + expect(parsed.details).toMatch(/different columns/i); + }, 30_000); + + it("rejects a single file with 422", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny-a.csv", contentType: "text/csv", content: CSV_A }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/merge-csvs", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(422); + const parsed = JSON.parse(res.body); + expect(parsed.details).toMatch(/at least two/i); + }, 30_000); +}); diff --git a/tests/integration/powerpoint-to-pdf.test.ts b/tests/integration/powerpoint-to-pdf.test.ts new file mode 100644 index 00000000..79c567fc --- /dev/null +++ b/tests/integration/powerpoint-to-pdf.test.ts @@ -0,0 +1,81 @@ +// powerpoint-to-pdf integration suite. +// Requires LibreOffice (soffice). Skips locally (soffice absent on dev Macs); +// the Docker compose smoke is the real proof that this tool works end to end +// against the containerised LibreOffice install. + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { sofficeAvailable } from "@snapotter/doc-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const PPTX = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.pptx")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(filename: string, content: Buffer) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename, contentType: "application/octet-stream", content }, + { name: "settings", content: JSON.stringify({}) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/powerpoint-to-pdf", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!sofficeAvailable())("powerpoint-to-pdf (requires soffice)", () => { + it("returns 202 (long hint) and the job completes with a PDF", async () => { + const res = await runTool("tiny.pptx", PPTX); + expect(res.statusCode).toBe(202); + const { jobId } = JSON.parse(res.body); + // Poll the durable row until terminal (the long hint skips the sync window). + const { db, schema } = await import("../../apps/api/src/db/index.js"); + const { eq } = await import("drizzle-orm"); + let row: { status: string; outputRefs: unknown } | undefined; + for (let i = 0; i < 120; i++) { + [row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); + if (row && ["completed", "failed", "canceled"].includes(row.status)) break; + await new Promise((r) => setTimeout(r, 500)); + } + expect(row?.status).toBe("completed"); + const outName = (row?.outputRefs as string[])[0].split("/").pop() as string; + const dl = await testApp.app.inject({ + method: "GET", + url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`, + }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.subarray(0, 5).toString()).toBe("%PDF-"); + }, 90_000); +}); + +it("rejects unsupported file types with 415", async () => { + const { body, contentType } = createMultipartPayload([ + { + name: "file", + filename: "readme.txt", + contentType: "text/plain", + content: Buffer.from("hello"), + }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/powerpoint-to-pdf", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + expect(res.statusCode).toBe(415); +}); diff --git a/tests/integration/split-csv.test.ts b/tests/integration/split-csv.test.ts index cbcdd144..a520d221 100644 --- a/tests/integration/split-csv.test.ts +++ b/tests/integration/split-csv.test.ts @@ -5,6 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; const CSV = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.csv")); +const TSV = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.tsv")); let testApp: TestApp; let adminToken: string; @@ -60,6 +61,45 @@ describe("split-csv (pure JS, no skipIf)", () => { } }, 30_000); + it("splits a TSV file correctly with rowsPerFile 1", async () => { + const { body: tsvBody, contentType: tsvCt } = createMultipartPayload([ + { + name: "file", + filename: "tiny.tsv", + contentType: "text/tab-separated-values", + content: TSV, + }, + { name: "settings", content: JSON.stringify({ rowsPerFile: 1, keepHeader: true }) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/split-csv", + headers: { authorization: `Bearer ${adminToken}`, "content-type": tsvCt }, + body: tsvBody, + }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ + method: "GET", + url: envelope.downloadUrl, + }); + expect(dl.statusCode).toBe(200); + + const zip = new AdmZip(Buffer.from(dl.rawPayload)); + const entries = zip.getEntries(); + // TSV has 2 data rows -> 2 parts + expect(entries.length).toBe(2); + + // Each part should contain the header + for (const entry of entries) { + const text = entry.getData().toString("utf8"); + expect(text).toContain("id"); + expect(text).toContain("name"); + } + }, 30_000); + it("splits with keepHeader false omits the header from parts", async () => { const res = await runTool({ rowsPerFile: 2, keepHeader: false }); expect(res.statusCode).toBe(200); diff --git a/tests/integration/to-epub.test.ts b/tests/integration/to-epub.test.ts new file mode 100644 index 00000000..711e089b --- /dev/null +++ b/tests/integration/to-epub.test.ts @@ -0,0 +1,73 @@ +// to-epub integration suite. +// Requires pandoc. Skips locally (pandoc absent on dev Macs); +// the Docker compose smoke is the real proof. + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { pandocAvailable } from "@snapotter/doc-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MD = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.md")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(filename: string, content: Buffer) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename, contentType: "application/octet-stream", content }, + { name: "settings", content: JSON.stringify({}) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/to-epub", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!pandocAvailable())("to-epub (requires pandoc)", () => { + it("converts tiny.md to EPUB with PK magic", async () => { + const res = await runTool("tiny.md", MD); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ + method: "GET", + url: envelope.downloadUrl, + }); + expect(dl.statusCode).toBe(200); + // EPUB is a ZIP archive (PK magic bytes) + expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK"); + }, 30_000); +}); + +// Ungated: runs locally without pandoc +it("rejects unsupported file types with 415", async () => { + const { body, contentType } = createMultipartPayload([ + { + name: "file", + filename: "data.csv", + contentType: "text/csv", + content: Buffer.from("a,b\n1,2"), + }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/to-epub", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + expect(res.statusCode).toBe(415); +}); diff --git a/tests/integration/xml-to-csv.test.ts b/tests/integration/xml-to-csv.test.ts new file mode 100644 index 00000000..70c6fe12 --- /dev/null +++ b/tests/integration/xml-to-csv.test.ts @@ -0,0 +1,58 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(filename: string, content: Buffer, settings: Record = {}) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename, contentType: "application/octet-stream", content }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/xml-to-csv", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe("xml-to-csv (pure JS, no skipIf)", () => { + it("converts XML with 2 repeated elements to CSV with 2 data rows", async () => { + const xml = Buffer.from( + 'Ada36Grace85', + ); + const res = await runTool("data.xml", xml); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + expect(envelope.rows).toBe(2); + + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + + const text = dl.payload; + const lines = text.trim().split("\n"); + // header + 2 data rows + expect(lines.length).toBe(3); + expect(text).toContain("Ada"); + expect(text).toContain("Grace"); + }, 30_000); + + it("rejects XML with no repeating elements", async () => { + const xml = Buffer.from('value'); + const res = await runTool("flat.xml", xml); + expect(res.statusCode).toBe(422); + const parsed = JSON.parse(res.body); + expect(parsed.details).toMatch(/no repeating elements/i); + }, 30_000); +}); diff --git a/tests/integration/yaml-json.test.ts b/tests/integration/yaml-json.test.ts new file mode 100644 index 00000000..a335d5e9 --- /dev/null +++ b/tests/integration/yaml-json.test.ts @@ -0,0 +1,76 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const YAML = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.yaml")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(filename: string, content: Buffer, settings: Record = {}) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename, contentType: "application/octet-stream", content }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/yaml-json", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe("yaml-json (pure JS, no skipIf)", () => { + it("converts tiny.yaml to JSON containing 'alpha'", async () => { + const res = await runTool("tiny.yaml", YAML); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + + const json = dl.payload; + expect(json).toContain("alpha"); + const parsed = JSON.parse(json); + expect(parsed.name).toBe("SnapOtter"); + }, 30_000); + + it("round-trips: converts the produced JSON back to YAML", async () => { + // First: yaml -> json + const res1 = await runTool("tiny.yaml", YAML); + expect(res1.statusCode).toBe(200); + const env1 = JSON.parse(res1.body); + const dl1 = await testApp.app.inject({ method: "GET", url: env1.downloadUrl }); + const jsonBuf = Buffer.from(dl1.payload, "utf8"); + + // Second: json -> yaml + const res2 = await runTool("tiny.json", jsonBuf); + expect(res2.statusCode).toBe(200); + const env2 = JSON.parse(res2.body); + const dl2 = await testApp.app.inject({ method: "GET", url: env2.downloadUrl }); + expect(dl2.statusCode).toBe(200); + + const yamlText = dl2.payload; + expect(yamlText).toContain("alpha"); + expect(yamlText).toContain("SnapOtter"); + }, 30_000); + + it("rejects malformed YAML with 422", async () => { + const bad = Buffer.from("a: [unclosed"); + const res = await runTool("bad.yaml", bad); + expect(res.statusCode).toBe(422); + const parsed = JSON.parse(res.body); + expect(parsed.details).toMatch(/not valid yaml/i); + }, 30_000); +}); diff --git a/tests/unit/ai/ssrf-prescan-regex.test.ts b/tests/unit/ai/ssrf-prescan-regex.test.ts new file mode 100644 index 00000000..96c74364 --- /dev/null +++ b/tests/unit/ai/ssrf-prescan-regex.test.ts @@ -0,0 +1,67 @@ +// Pure-regex unit test for the SSRF pre-scan in doc_html_pdf.py. +// Runs python3 against the actual module regexes (no weasyprint needed). +// Mirrors the step-3 verification matrix from the fix ticket. + +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { hasPython } from "../../helpers/python-gate.js"; + +const SCRIPT_DIR = join(process.cwd(), "packages", "ai", "python"); + +/** Run a python3 snippet that imports regexes from the actual module and tests a case. */ +function testRegex(html: string, patternName: string): boolean { + const code = [ + "import sys, os", + `sys.path.insert(0, ${JSON.stringify(SCRIPT_DIR)})`, + "from doc_html_pdf import _REMOTE_REF_RE, _REMOTE_CSS_URL_RE", + `pat = ${patternName}`, + `m = pat.search(${JSON.stringify(html)})`, + "print('MATCH' if m else 'NO_MATCH')", + ].join("; "); + const res = spawnSync("python3", ["-c", code], { encoding: "utf8", timeout: 5000 }); + if (res.status !== 0) throw new Error(`python3 failed: ${res.stderr}`); + return res.stdout.trim() === "MATCH"; +} + +describe.skipIf(!hasPython)("SSRF pre-scan regexes (doc_html_pdf.py)", () => { + describe("_REMOTE_REF_RE", () => { + it("matches double-slash http img", () => { + expect(testRegex('', "_REMOTE_REF_RE")).toBe(true); + }); + + it("matches single-slash http img (pandoc rewrite)", () => { + expect(testRegex('', "_REMOTE_REF_RE")).toBe(true); + }); + + it("matches double-slash https img", () => { + expect(testRegex('', "_REMOTE_REF_RE")).toBe(true); + }); + + it("matches single-slash https img", () => { + expect(testRegex('', "_REMOTE_REF_RE")).toBe(true); + }); + + it("does NOT match data: URIs", () => { + expect(testRegex('', "_REMOTE_REF_RE")).toBe(false); + }); + + it("does NOT match relative paths", () => { + expect(testRegex('', "_REMOTE_REF_RE")).toBe(false); + }); + }); + + describe("_REMOTE_CSS_URL_RE", () => { + it("matches single-slash CSS url()", () => { + expect(testRegex("url(https:/h/a.css)", "_REMOTE_CSS_URL_RE")).toBe(true); + }); + + it("matches double-slash CSS url()", () => { + expect(testRegex("url(https://h/a.css)", "_REMOTE_CSS_URL_RE")).toBe(true); + }); + + it("does NOT match data: CSS url()", () => { + expect(testRegex("url(data:image/png;base64,AA==)", "_REMOTE_CSS_URL_RE")).toBe(false); + }); + }); +}); diff --git a/tests/unit/doc-engine/libreoffice.test.ts b/tests/unit/doc-engine/libreoffice.test.ts new file mode 100644 index 00000000..9fa59361 --- /dev/null +++ b/tests/unit/doc-engine/libreoffice.test.ts @@ -0,0 +1,27 @@ +import { parseConvertTarget } from "@snapotter/doc-engine"; +import { describe, expect, it } from "vitest"; + +describe("parseConvertTarget", () => { + it("returns a bare extension unchanged", () => { + const result = parseConvertTarget("pdf"); + expect(result).toEqual({ ext: "pdf", convertTo: "pdf" }); + }); + + it("splits on the first colon for qualified filter targets", () => { + const result = parseConvertTarget("docx:MS Word 2007 XML"); + expect(result).toEqual({ ext: "docx", convertTo: "docx:MS Word 2007 XML" }); + }); + + it("handles filter names with colons after the first", () => { + const result = parseConvertTarget("csv:Text - txt - csv (StarCalc):44,34,76,1"); + expect(result).toEqual({ + ext: "csv", + convertTo: "csv:Text - txt - csv (StarCalc):44,34,76,1", + }); + }); + + it("handles single-character extensions", () => { + const result = parseConvertTarget("a:SomeFilter"); + expect(result).toEqual({ ext: "a", convertTo: "a:SomeFilter" }); + }); +}); diff --git a/tests/unit/doc-engine/pandoc.test.ts b/tests/unit/doc-engine/pandoc.test.ts new file mode 100644 index 00000000..bedb89ad --- /dev/null +++ b/tests/unit/doc-engine/pandoc.test.ts @@ -0,0 +1,34 @@ +import { readFileSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pandocAvailable, runPandoc } from "@snapotter/doc-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +describe("pandocAvailable", () => { + it("returns a boolean without throwing", () => { + expect(typeof pandocAvailable()).toBe("boolean"); + }); +}); + +describe.skipIf(!pandocAvailable())("runPandoc (requires pandoc)", () => { + let tmpDir: string; + + beforeAll(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "pandoc-test-")); + }); + + afterAll(async () => { + await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + }); + + it("converts a trivial markdown file to html", async () => { + const mdPath = join(tmpDir, "test.md"); + const htmlPath = join(tmpDir, "test.html"); + await writeFile(mdPath, "# Hello\n\nWorld\n"); + await runPandoc(mdPath, htmlPath, { extraArgs: ["--standalone"] }); + const html = readFileSync(htmlPath, "utf8"); + expect(html).toContain("Hello"); + expect(html).toContain("<"); + }, 30_000); +}); diff --git a/tests/unit/shared/modality.test.ts b/tests/unit/shared/modality.test.ts index 507a9fbb..2ef3a410 100644 --- a/tests/unit/shared/modality.test.ts +++ b/tests/unit/shared/modality.test.ts @@ -24,7 +24,11 @@ describe("modality metadata", () => { for (const tool of TOOLS) { expect(validModalities).toContain(tool.modality); expect(Array.isArray(tool.acceptedInputs)).toBe(true); - expect(tool.acceptedInputs.length).toBeGreaterThan(0); + // Empty acceptedInputs is valid for file-modality tools that accept any file + // (e.g. create-zip); the factory 415 gate skips when the list is empty. + if (tool.modality !== "file" || tool.acceptedInputs.length > 0) { + expect(tool.acceptedInputs.length).toBeGreaterThan(0); + } expect(["fast", "long"]).toContain(tool.executionHint); } });