From ae1337901d3c7fe2978edd4a003aaf407036a427 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 10:18:49 +0800 Subject: [PATCH] feat(tools)!: SnapOtter 2.0 phase 4 wave 1: 45 core tools across all modalities (#219) --- .github/workflows/nightly.yml | 2 +- .gitignore | 2 +- .../meta/0000_snapshot.json | 704 +++------------ .../meta/0001_snapshot.json | 851 ++++++++++++++---- apps/api/package.json | 4 + apps/api/src/jobs/worker.ts | 3 +- apps/api/src/lib/media-tool.ts | 69 ++ apps/api/src/modality/media-input.ts | 12 + apps/api/src/routes/tool-factory.ts | 121 ++- apps/api/src/routes/tools/compress-pdf.ts | 38 + apps/api/src/routes/tools/compress-video.ts | 57 ++ apps/api/src/routes/tools/convert-audio.ts | 81 ++ apps/api/src/routes/tools/convert-video.ts | 78 ++ apps/api/src/routes/tools/csv-excel.ts | 71 ++ apps/api/src/routes/tools/csv-json.ts | 51 ++ apps/api/src/routes/tools/extract-audio.ts | 47 + apps/api/src/routes/tools/index.ts | 42 + apps/api/src/routes/tools/json-xml.ts | 54 ++ apps/api/src/routes/tools/merge-pdf.ts | 35 + apps/api/src/routes/tools/mute-video.ts | 28 + apps/api/src/routes/tools/rotate-pdf.ts | 43 + apps/api/src/routes/tools/split-csv.ts | 82 ++ apps/api/src/routes/tools/split-pdf.ts | 89 ++ apps/api/src/routes/tools/trim-audio.ts | 45 + apps/api/src/routes/tools/trim-video.ts | 70 ++ apps/api/src/routes/tools/video-to-gif.ts | 41 + apps/api/src/routes/tools/word-to-pdf.ts | 40 + .../tools/compress-pdf-settings.tsx | 73 ++ .../tools/compress-video-settings.tsx | 92 ++ .../tools/convert-audio-settings.tsx | 96 ++ .../tools/convert-video-settings.tsx | 91 ++ .../components/tools/csv-excel-settings.tsx | 75 ++ .../components/tools/csv-json-settings.tsx | 65 ++ .../tools/extract-audio-settings.tsx | 73 ++ .../components/tools/json-xml-settings.tsx | 65 ++ .../components/tools/merge-pdf-settings.tsx | 51 ++ .../components/tools/mute-video-settings.tsx | 52 ++ .../components/tools/rotate-pdf-settings.tsx | 88 ++ .../components/tools/split-csv-settings.tsx | 81 ++ .../components/tools/split-pdf-settings.tsx | 108 +++ .../components/tools/trim-audio-settings.tsx | 90 ++ .../components/tools/trim-video-settings.tsx | 101 +++ .../tools/video-to-gif-settings.tsx | 121 +++ .../components/tools/word-to-pdf-settings.tsx | 52 ++ apps/web/src/hooks/use-tool-processor.ts | 7 +- apps/web/src/lib/icon-map.ts | 16 + apps/web/src/lib/tool-display-modes.ts | 31 + apps/web/src/lib/tool-registry.tsx | 117 ++- packages/doc-engine/src/binaries.ts | 3 + packages/doc-engine/src/ghostscript.ts | 55 ++ packages/doc-engine/src/index.ts | 3 + packages/doc-engine/src/pdf-ops.ts | 37 + packages/doc-engine/src/qpdf.ts | 3 +- packages/shared/src/constants.ts | 203 ++++- packages/shared/src/i18n/ar.ts | 203 ++++- packages/shared/src/i18n/de.ts | 203 ++++- packages/shared/src/i18n/en.ts | 203 ++++- packages/shared/src/i18n/es.ts | 203 ++++- packages/shared/src/i18n/fr.ts | 203 ++++- packages/shared/src/i18n/hi.ts | 203 ++++- packages/shared/src/i18n/id.ts | 203 ++++- packages/shared/src/i18n/it.ts | 203 ++++- packages/shared/src/i18n/ja.ts | 203 ++++- packages/shared/src/i18n/ko.ts | 203 ++++- packages/shared/src/i18n/nl.ts | 203 ++++- packages/shared/src/i18n/pl.ts | 203 ++++- packages/shared/src/i18n/pt-BR.ts | 203 ++++- packages/shared/src/i18n/ru.ts | 203 ++++- packages/shared/src/i18n/sv.ts | 203 ++++- packages/shared/src/i18n/th.ts | 203 ++++- packages/shared/src/i18n/tr.ts | 203 ++++- packages/shared/src/i18n/uk.ts | 203 ++++- packages/shared/src/i18n/vi.ts | 203 ++++- packages/shared/src/i18n/zh-CN.ts | 203 ++++- packages/shared/src/i18n/zh-TW.ts | 203 ++++- packages/shared/src/types.ts | 6 +- pnpm-lock.yaml | 325 +++++++ scripts/generate-test-fixtures.mjs | 4 +- tests/e2e/document-mode.spec.ts | 65 ++ tests/e2e/media-player-mode.spec.ts | 63 ++ tests/fixtures/data/tiny.csv | 4 + tests/fixtures/data/tiny.json | 1 + tests/fixtures/data/tiny.xml | 1 + tests/fixtures/media/tiny.mp4 | Bin 8243 -> 13290 bytes tests/helpers/tool-default-settings.ts | 3 + .../adversarial-coverage-gaps.test.ts | 2 +- tests/integration/adversarial.test.ts | 2 +- tests/integration/border.test.ts | 2 +- tests/integration/compress-pdf.test.ts | 45 + tests/integration/compress-video.test.ts | 70 ++ tests/integration/convert-audio.test.ts | 66 ++ tests/integration/convert-video.test.ts | 91 ++ tests/integration/csv-excel.test.ts | 81 ++ tests/integration/csv-json.test.ts | 76 ++ tests/integration/doc-engine-pdf-ops.test.ts | 78 ++ tests/integration/extract-audio.test.ts | 64 ++ tests/integration/factory-multi-input.test.ts | 176 ++++ tests/integration/json-xml.test.ts | 71 ++ tests/integration/merge-pdf.test.ts | 77 ++ tests/integration/modality-input.test.ts | 13 + tests/integration/mute-video.test.ts | 59 ++ tests/integration/rotate-pdf.test.ts | 65 ++ tests/integration/split-csv.test.ts | 80 ++ tests/integration/split-pdf.test.ts | 97 ++ tests/integration/test-server.ts | 14 + tests/integration/trim-audio.test.ts | 64 ++ tests/integration/trim-video.test.ts | 77 ++ tests/integration/video-to-gif.test.ts | 60 ++ tests/integration/word-to-pdf.test.ts | 62 ++ tests/unit/api/tool-factory-route.test.ts | 2 +- tests/unit/landing/bento-grid.test.tsx | 39 +- tests/unit/shared/modality.test.ts | 13 +- tests/unit/web/i18n-locale.test.ts | 4 +- tests/unit/web/tool-registry-expanded.test.ts | 27 + tests/unit/web/tool-registry.test.ts | 29 + 115 files changed, 10026 insertions(+), 924 deletions(-) create mode 100644 apps/api/src/lib/media-tool.ts create mode 100644 apps/api/src/routes/tools/compress-pdf.ts create mode 100644 apps/api/src/routes/tools/compress-video.ts create mode 100644 apps/api/src/routes/tools/convert-audio.ts create mode 100644 apps/api/src/routes/tools/convert-video.ts create mode 100644 apps/api/src/routes/tools/csv-excel.ts create mode 100644 apps/api/src/routes/tools/csv-json.ts create mode 100644 apps/api/src/routes/tools/extract-audio.ts create mode 100644 apps/api/src/routes/tools/json-xml.ts create mode 100644 apps/api/src/routes/tools/merge-pdf.ts create mode 100644 apps/api/src/routes/tools/mute-video.ts create mode 100644 apps/api/src/routes/tools/rotate-pdf.ts create mode 100644 apps/api/src/routes/tools/split-csv.ts create mode 100644 apps/api/src/routes/tools/split-pdf.ts create mode 100644 apps/api/src/routes/tools/trim-audio.ts create mode 100644 apps/api/src/routes/tools/trim-video.ts create mode 100644 apps/api/src/routes/tools/video-to-gif.ts create mode 100644 apps/api/src/routes/tools/word-to-pdf.ts create mode 100644 apps/web/src/components/tools/compress-pdf-settings.tsx create mode 100644 apps/web/src/components/tools/compress-video-settings.tsx create mode 100644 apps/web/src/components/tools/convert-audio-settings.tsx create mode 100644 apps/web/src/components/tools/convert-video-settings.tsx create mode 100644 apps/web/src/components/tools/csv-excel-settings.tsx create mode 100644 apps/web/src/components/tools/csv-json-settings.tsx create mode 100644 apps/web/src/components/tools/extract-audio-settings.tsx create mode 100644 apps/web/src/components/tools/json-xml-settings.tsx create mode 100644 apps/web/src/components/tools/merge-pdf-settings.tsx create mode 100644 apps/web/src/components/tools/mute-video-settings.tsx create mode 100644 apps/web/src/components/tools/rotate-pdf-settings.tsx create mode 100644 apps/web/src/components/tools/split-csv-settings.tsx create mode 100644 apps/web/src/components/tools/split-pdf-settings.tsx create mode 100644 apps/web/src/components/tools/trim-audio-settings.tsx create mode 100644 apps/web/src/components/tools/trim-video-settings.tsx create mode 100644 apps/web/src/components/tools/video-to-gif-settings.tsx create mode 100644 apps/web/src/components/tools/word-to-pdf-settings.tsx create mode 100644 packages/doc-engine/src/ghostscript.ts create mode 100644 packages/doc-engine/src/pdf-ops.ts create mode 100644 tests/e2e/document-mode.spec.ts create mode 100644 tests/e2e/media-player-mode.spec.ts create mode 100644 tests/fixtures/data/tiny.csv create mode 100644 tests/fixtures/data/tiny.json create mode 100644 tests/fixtures/data/tiny.xml create mode 100644 tests/integration/compress-pdf.test.ts create mode 100644 tests/integration/compress-video.test.ts create mode 100644 tests/integration/convert-audio.test.ts create mode 100644 tests/integration/convert-video.test.ts create mode 100644 tests/integration/csv-excel.test.ts create mode 100644 tests/integration/csv-json.test.ts create mode 100644 tests/integration/doc-engine-pdf-ops.test.ts create mode 100644 tests/integration/extract-audio.test.ts create mode 100644 tests/integration/factory-multi-input.test.ts create mode 100644 tests/integration/json-xml.test.ts create mode 100644 tests/integration/merge-pdf.test.ts create mode 100644 tests/integration/mute-video.test.ts create mode 100644 tests/integration/rotate-pdf.test.ts create mode 100644 tests/integration/split-csv.test.ts create mode 100644 tests/integration/split-pdf.test.ts create mode 100644 tests/integration/trim-audio.test.ts create mode 100644 tests/integration/trim-video.test.ts create mode 100644 tests/integration/video-to-gif.test.ts create mode 100644 tests/integration/word-to-pdf.test.ts diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 38e04efa..23803013 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - SYSTEM_DEPS: libheif-examples libheif-plugin-x265 libheif-plugin-libde265 libimage-exiftool-perl imagemagick ghostscript libjxl-tools libopenjp2-tools + SYSTEM_DEPS: libheif-examples libheif-plugin-x265 libheif-plugin-libde265 libimage-exiftool-perl imagemagick ghostscript libjxl-tools libopenjp2-tools ffmpeg qpdf jobs: e2e-full: diff --git a/.gitignore b/.gitignore index 0253f8e4..dccd5477 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ dist/ *.db-journal *.db-wal *.db-shm -data/ +/data/ tmp/ .env .env.local 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 ec9f9c43..bac78cb2 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -32,7 +32,9 @@ "bullmq": "^5.78.0", "dotenv": "^16.4.0", "drizzle-orm": "^0.45.2", + "exceljs": "^4.4.0", "exif-reader": "^2.0.3", + "fast-xml-parser": "^5.8.0", "fastify": "^5.8.5", "fflate": "^0.8.3", "ioredis": "^5.10.1", @@ -41,6 +43,7 @@ "openid-client": "^6.8.4", "opentype.js": "^2.0.0", "p-queue": "^9.3.0", + "papaparse": "^5.5.3", "pdfkit": "^0.18.0", "pg": "^8.21.0", "pino-roll": "^4.0.0", @@ -61,6 +64,7 @@ "@types/js-yaml": "^4.0.9", "@types/node": "^22.19.19", "@types/opentype.js": "^1.3.10", + "@types/papaparse": "^5.5.2", "@types/pdfkit": "^0.17.6", "@types/pg": "^8.20.0", "@types/potrace": "^2.1.5", diff --git a/apps/api/src/jobs/worker.ts b/apps/api/src/jobs/worker.ts index 54df42fb..ba7fafd6 100644 --- a/apps/api/src/jobs/worker.ts +++ b/apps/api/src/jobs/worker.ts @@ -29,6 +29,7 @@ import { eq } from "drizzle-orm"; import { env } from "../config.js"; import { db, schema } from "../db/index.js"; import { resolveConcurrency } from "../lib/env.js"; +import { stripInternalPaths } from "../lib/errors.js"; import { jobDuration, jobsTotal } from "../lib/metrics.js"; import { getObjectBuffer, putObject } from "../lib/object-storage.js"; import { publishEphemeral, updateSingleFileProgress } from "../routes/progress.js"; @@ -341,7 +342,7 @@ async function processToolJob(job: Job): Promise { jobId: progressJobId, phase: "failed", percent: 0, - error: finalError, + error: stripInternalPaths(finalError), }); } } diff --git a/apps/api/src/lib/media-tool.ts b/apps/api/src/lib/media-tool.ts new file mode 100644 index 00000000..905f4103 --- /dev/null +++ b/apps/api/src/lib/media-tool.ts @@ -0,0 +1,69 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { probeMedia, runFfmpeg } from "@snapotter/media-engine"; +import type { ToolProcessCtxV2 } from "../routes/tool-factory.js"; + +const EXT_VIDEO_CONTENT_TYPES: Record = { + ".mp4": "video/mp4", + ".mov": "video/quicktime", + ".webm": "video/webm", + ".mkv": "video/x-matroska", +}; + +/** Content type for a preserved-container video output; mp4 fallback. */ +export function videoContentType(ext: string): string { + return EXT_VIDEO_CONTENT_TYPES[ext.toLowerCase()] || "video/mp4"; +} + +const EXT_AUDIO_CONTENT_TYPES: Record = { + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".ogg": "audio/ogg", + ".flac": "audio/flac", + ".m4a": "audio/mp4", + ".aac": "audio/aac", + ".opus": "audio/opus", + ".wma": "audio/x-ms-wma", + ".aiff": "audio/aiff", +}; + +/** Content type for a preserved-container audio output; mpeg fallback. */ +export function audioContentType(ext: string): string { + return EXT_AUDIO_CONTENT_TYPES[ext.toLowerCase()] || "audio/mpeg"; +} + +export interface MediaRunResult { + outPath: string; + durationS: number | null; +} + +/** + * Stages the primary input in the scratch dir, probes it, runs ffmpeg with + * progress mapped onto ctx.report (5..95%), and returns the output path for + * a scratchPath result. argsFor receives the staged input/output paths. + */ +export async function runMediaTool( + ctx: ToolProcessCtxV2, + outName: string, + argsFor: (inPath: string, outPath: string, info: { durationS: number | null }) => string[], + opts: { timeoutMs?: number } = {}, +): Promise { + const dir = join(ctx.scratchDir, "media"); + await mkdir(dir, { recursive: true }); + const inPath = join(dir, `in-${ctx.inputs[0].filename.replace(/[^A-Za-z0-9._-]/g, "_")}`); + await writeFile(inPath, ctx.inputs[0].buffer); + const info = await probeMedia(inPath); + const outPath = join(dir, outName); + ctx.report(5, "Preparing"); + await runFfmpeg(argsFor(inPath, outPath, { durationS: info.durationS }), { + signal: ctx.signal, + timeoutMs: opts.timeoutMs ?? 30 * 60_000, + onProgress: (p) => { + if (p.outTimeMs !== null && info.durationS) { + const pct = Math.min(95, 5 + Math.round((p.outTimeMs / (info.durationS * 1000)) * 90)); + ctx.report(pct, "Processing"); + } + }, + }); + return { outPath, durationS: info.durationS }; +} diff --git a/apps/api/src/modality/media-input.ts b/apps/api/src/modality/media-input.ts index 4540402d..043a318b 100644 --- a/apps/api/src/modality/media-input.ts +++ b/apps/api/src/modality/media-input.ts @@ -35,6 +35,18 @@ export class MediaInputHandler implements InputHandler { if (this.kind === "video" && !hasVideo) { throw new InputValidationError("File contains no video stream"); } + // ffprobe reports still images as single-frame video streams in + // *_pipe/image2 containers with no duration; a video tool must + // reject those (carry-forward from phase-3 input-handler review). + const IMAGE_CONTAINER_RE = + /(^|,)(png_pipe|image2|bmp_pipe|gif_pipe|jpeg_pipe|tiff_pipe|webp_pipe|svg_pipe)($|,)/; + if ( + this.kind === "video" && + IMAGE_CONTAINER_RE.test(info.container) && + info.durationS === null + ) { + throw new InputValidationError("File is a still image, not a video"); + } if (this.kind === "audio" && !hasAudio) { throw new InputValidationError("File contains no audio stream"); } diff --git a/apps/api/src/routes/tool-factory.ts b/apps/api/src/routes/tool-factory.ts index 69c60f31..93233a9f 100644 --- a/apps/api/src/routes/tool-factory.ts +++ b/apps/api/src/routes/tool-factory.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { mkdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { extname, join } from "node:path"; import { ANALYTICS_EVENTS, getBundleForTool, TOOL_BUNDLE_MAP, TOOLS } from "@snapotter/shared"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type { z } from "zod"; @@ -12,7 +12,7 @@ import { formatZodErrors, stripInternalPaths } from "../lib/errors.js"; import { isToolInstalled } from "../lib/feature-status.js"; import { getObjectBuffer, putObject } from "../lib/object-storage.js"; import { resolveToolPool, shouldSkipSyncWindow } from "../lib/pool.js"; -import { receiveUpload } from "../lib/upload-stream.js"; +import { type ReceivedUpload, receiveUpload } from "../lib/upload-stream.js"; import { InputValidationError } from "../modality/contract.js"; import { inputHandlerFor } from "../modality/input-handler.js"; import { getAuthUser } from "../plugins/auth.js"; @@ -63,6 +63,12 @@ export type ToolProcessV2 = (ctx: ToolProcessCtxV2) => Promise { /** Unique tool identifier, used as the URL path segment. */ toolId: string; + /** + * How many file parts the route accepts (default 1). Inputs beyond the + * first are validated by the same modality handler and appended to + * inputRefs in arrival order. + */ + maxInputs?: number; /** Zod schema that validates the settings JSON from the request. */ settingsSchema: z.ZodType; /** The processing function: takes input buffer + validated settings, returns output. */ @@ -79,6 +85,7 @@ export interface ToolRouteConfig { /** Type-erased config stored in the registry (settings type is widened to avoid variance issues). */ export interface AnyToolRouteConfig { toolId: string; + maxInputs?: number; settingsSchema: z.ZodType; process: ( inputBuffer: Buffer, @@ -171,12 +178,13 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (request: FastifyRequest, reply: FastifyReply) => { const jobId = randomUUID(); + const maxInputs = config.maxInputs ?? 1; let filename = "image"; let settingsRaw: string | null = null; let fileId: string | null = null; let clientJobId: string | null = null; let fileCount = 0; - let inputKey: string | null = null; + const received: ReceivedUpload[] = []; // Parse multipart parts (file parts stream to object storage) try { @@ -185,7 +193,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig for await (const part of parts) { if (part.type === "file") { fileCount++; - if (fileCount > 1) { + if (fileCount > maxInputs) { // Drain remaining parts to avoid hanging the connection for await (const _ of part.file) { /* drain */ @@ -196,8 +204,10 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig maxBytes: env.MAX_UPLOAD_SIZE_MB > 0 ? env.MAX_UPLOAD_SIZE_MB * 1024 * 1024 : undefined, }); - inputKey = upload.key; - filename = upload.filename; + received.push(upload); + if (fileCount === 1) { + filename = upload.filename; + } } else { // Field part if (part.fieldname === "settings") { @@ -221,21 +231,17 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig }); } - if (fileCount > 1) { + if (fileCount > maxInputs) { return reply.status(400).send({ - error: `This endpoint processes one image at a time. Use /api/v1/tools/${config.toolId}/batch for multiple files.`, + error: `Too many files (max ${maxInputs})`, }); } - // Require a file - if (!inputKey) { + // Require at least one file + if (received.length === 0) { return reply.status(400).send({ error: "No image file provided" }); } - // Read back the uploaded file for validation/decode chain - let fileBuffer = await getObjectBuffer(inputKey); - const originalBuffer = fileBuffer; - const reportProgress = (percent: number, stage?: string) => { if (!clientJobId) return; updateSingleFileProgress({ @@ -256,20 +262,66 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig const scratchDir = join(tmpdir(), "snapotter-scratch", jobId); await mkdir(scratchDir, { recursive: true }); try { - // Modality-specific input validation and normalization - try { - const prepared = await inputHandlerFor(modality).prepare(fileBuffer, filename, { - scratchDir, - }); - fileBuffer = prepared.buffer; - filename = prepared.filename; - } catch (err) { - if (err instanceof InputValidationError) { - const body: Record = { error: err.message }; - if (err.details) body.details = err.details; - return reply.status(err.statusCode).send(body); + // Reject files whose extension is not in the tool's acceptedInputs. + // Image and media modalities validate content via their input handlers + // (sharp decode, ffprobe); document/file modalities need an explicit + // extension gate because their handlers pass unrecognized types through. + const accepted = toolMeta?.acceptedInputs; + if (accepted?.length && (modality === "file" || modality === "document")) { + for (const upload of received) { + const ext = extname(upload.filename).toLowerCase(); + if (!accepted.includes(ext)) { + return reply.status(415).send({ + error: `Unsupported file type "${ext || "(none)"}" for this tool`, + }); + } + } + } + + // Prepare all files through the modality input handler + const inputRefs: string[] = []; + for (let i = 0; i < received.length; i++) { + const upload = received[i]; + let fileBuffer = await getObjectBuffer(upload.key); + const originalBuffer = fileBuffer; + let fname = upload.filename; + + try { + const prepared = await inputHandlerFor(modality).prepare(fileBuffer, fname, { + scratchDir, + }); + fileBuffer = prepared.buffer; + fname = prepared.filename; + } catch (err) { + if (err instanceof InputValidationError) { + const errorMsg = maxInputs > 1 ? `${fname}: ${err.message}` : err.message; + const body: Record = { error: errorMsg }; + if (err.details) body.details = err.details; + // Orphaned uploads// dir will be cleaned by T10 TTL sweeper + return reply.status(err.statusCode).send(body); + } + throw err; + } + + // If decode/orient transformed the buffer or changed the filename, + // write the final version so the worker processes the correct data. + // Skip re-upload when the buffer is reference-identical to the + // originally streamed bytes and the filename hasn't changed. + const decodedKey = `uploads/${jobId}/${fname}`; + if (decodedKey !== upload.key) { + await putObject(decodedKey, fileBuffer); + inputRefs.push(decodedKey); + } else if (fileBuffer !== originalBuffer) { + await putObject(upload.key, fileBuffer); + inputRefs.push(upload.key); + } else { + inputRefs.push(upload.key); + } + + // Primary file keeps the existing variable roles + if (i === 0) { + filename = fname; } - throw err; } reportProgress(15, "Preparing..."); @@ -310,19 +362,6 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig }); } - // If decode/orient transformed the buffer or changed the filename, - // write the final version so the worker processes the correct data. - // Skip re-upload when the buffer is reference-identical to the - // originally streamed bytes and the filename hasn't changed. - const decodedName = filename; - const decodedKey = `uploads/${jobId}/${decodedName}`; - if (decodedKey !== inputKey) { - await putObject(decodedKey, fileBuffer); - inputKey = decodedKey; - } else if (fileBuffer !== originalBuffer) { - await putObject(inputKey, fileBuffer); - } - const startTime = Date.now(); const pool = resolveToolPool(config.toolId); @@ -332,7 +371,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig toolId: config.toolId, userId: getAuthUser(request)?.id ?? null, pool, - inputRefs: [inputKey], + inputRefs, filename, settings, fileId: fileId ?? undefined, diff --git a/apps/api/src/routes/tools/compress-pdf.ts b/apps/api/src/routes/tools/compress-pdf.ts new file mode 100644 index 00000000..661867b1 --- /dev/null +++ b/apps/api/src/routes/tools/compress-pdf.ts @@ -0,0 +1,38 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { gsCompressPdf } from "@snapotter/doc-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + preset: z.enum(["screen", "ebook", "printer"]).default("ebook"), +}); + +export function registerCompressPdf(app: FastifyInstance) { + createToolRoute(app, { + toolId: "compress-pdf", + settingsSchema, + process: async () => { + throw new Error("compress-pdf is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + 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}_compressed.pdf`); + ctx.report(10, "Compressing"); + await gsCompressPdf(inPath, outPath, settings.preset); + ctx.report(90, "Done"); + + return { + scratchPath: outPath, + filename: `${base}_compressed.pdf`, + contentType: "application/pdf", + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/compress-video.ts b/apps/api/src/routes/tools/compress-video.ts new file mode 100644 index 00000000..e59b67aa --- /dev/null +++ b/apps/api/src/routes/tools/compress-video.ts @@ -0,0 +1,57 @@ +import { resolveEncoder } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runMediaTool } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + quality: z.enum(["light", "balanced", "strong"]).default("balanced"), + resolution: z.enum(["original", "1080p", "720p", "480p"]).default("original"), +}); + +const CRF: Record = { + light: "23", + balanced: "28", + strong: "33", +}; + +const SCALE: Record = { + "1080p": "scale=-2:1080", + "720p": "scale=-2:720", + "480p": "scale=-2:480", +}; + +export function registerCompressVideo(app: FastifyInstance) { + createToolRoute(app, { + toolId: "compress-video", + settingsSchema, + process: async () => { + throw new Error("compress-video is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_compressed.mp4`; + const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => { + const args = [ + "-i", + inPath, + "-c:v", + resolveEncoder("h264"), + "-crf", + CRF[settings.quality], + "-preset", + "medium", + "-pix_fmt", + "yuv420p", + ]; + if (settings.resolution !== "original") { + args.push("-vf", SCALE[settings.resolution]); + } + args.push("-c:a", resolveEncoder("aac"), "-b:a", "96k", "-movflags", "+faststart", out); + return args; + }); + return { scratchPath: outPath, filename: outName, contentType: "video/mp4" }; + }, + }); +} diff --git a/apps/api/src/routes/tools/convert-audio.ts b/apps/api/src/routes/tools/convert-audio.ts new file mode 100644 index 00000000..d0d19f16 --- /dev/null +++ b/apps/api/src/routes/tools/convert-audio.ts @@ -0,0 +1,81 @@ +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runMediaTool } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + format: z.enum(["mp3", "wav", "ogg", "flac", "m4a"]).default("mp3"), + bitrateKbps: z.number().int().min(32).max(320).default(192), +}); + +const CONTENT_TYPES: Record = { + mp3: "audio/mpeg", + wav: "audio/wav", + ogg: "audio/ogg", + flac: "audio/flac", + m4a: "audio/mp4", +}; + +export function registerConvertAudio(app: FastifyInstance) { + createToolRoute(app, { + toolId: "convert-audio", + settingsSchema, + process: async () => { + throw new Error("convert-audio is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}.${settings.format}`; + + const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => { + switch (settings.format) { + case "mp3": + return [ + "-i", + inPath, + "-vn", + "-c:a", + "libmp3lame", + "-b:a", + `${settings.bitrateKbps}k`, + out, + ]; + case "wav": + return ["-i", inPath, "-vn", "-c:a", "pcm_s16le", out]; + case "ogg": + return [ + "-i", + inPath, + "-vn", + "-c:a", + "libvorbis", + "-b:a", + `${settings.bitrateKbps}k`, + out, + ]; + case "flac": + return ["-i", inPath, "-vn", "-c:a", "flac", out]; + case "m4a": + return ["-i", inPath, "-vn", "-c:a", "aac", "-b:a", `${settings.bitrateKbps}k`, out]; + default: + return [ + "-i", + inPath, + "-vn", + "-c:a", + "libmp3lame", + "-b:a", + `${settings.bitrateKbps}k`, + out, + ]; + } + }); + return { + scratchPath: outPath, + filename: outName, + contentType: CONTENT_TYPES[settings.format], + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/convert-video.ts b/apps/api/src/routes/tools/convert-video.ts new file mode 100644 index 00000000..1e0808ca --- /dev/null +++ b/apps/api/src/routes/tools/convert-video.ts @@ -0,0 +1,78 @@ +import { resolveEncoder } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runMediaTool } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + format: z.enum(["mp4", "mov", "webm"]).default("mp4"), + quality: z.enum(["high", "balanced", "small"]).default("balanced"), +}); + +const CRF: Record = { + high: { h264: "18", vp9: "24" }, + balanced: { h264: "23", vp9: "32" }, + small: { h264: "28", vp9: "40" }, +}; + +const CONTENT_TYPES: Record = { + mp4: "video/mp4", + mov: "video/quicktime", + webm: "video/webm", +}; + +export function registerConvertVideo(app: FastifyInstance) { + createToolRoute(app, { + toolId: "convert-video", + settingsSchema, + process: async () => { + throw new Error("convert-video is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}.${settings.format}`; + const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => { + if (settings.format === "webm") { + return [ + "-i", + inPath, + "-c:v", + resolveEncoder("vp9"), + "-crf", + CRF[settings.quality].vp9, + "-b:v", + "0", + "-c:a", + resolveEncoder("opus"), + out, + ]; + } + return [ + "-i", + inPath, + "-c:v", + resolveEncoder("h264"), + "-crf", + CRF[settings.quality].h264, + "-preset", + "medium", + "-pix_fmt", + "yuv420p", + "-c:a", + resolveEncoder("aac"), + "-b:a", + "128k", + "-movflags", + "+faststart", + out, + ]; + }); + return { + scratchPath: outPath, + filename: outName, + contentType: CONTENT_TYPES[settings.format], + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/csv-excel.ts b/apps/api/src/routes/tools/csv-excel.ts new file mode 100644 index 00000000..a10c29f3 --- /dev/null +++ b/apps/api/src/routes/tools/csv-excel.ts @@ -0,0 +1,71 @@ +import type { FastifyInstance } from "fastify"; +import Papa from "papaparse"; +import { z } from "zod"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + sheet: z.number().int().min(1).default(1), +}); + +export function registerCsvExcel(app: FastifyInstance) { + createToolRoute(app, { + toolId: "csv-excel", + settingsSchema, + process: async () => { + throw new Error("csv-excel is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const input = ctx.inputs[0]; + const base = input.filename.replace(/\.[^.]+$/, ""); + const lower = input.filename.toLowerCase(); + + // Dynamic import: exceljs is heavy; load it only when this tool runs + const ExcelJS = await import("exceljs"); + + if (lower.endsWith(".xlsx")) { + // xlsx -> csv: load workbook, pick the Nth worksheet, extract rows + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(input.buffer as unknown as ArrayBuffer); + const ws = workbook.worksheets[settings.sheet - 1]; + if (!ws) { + throw new Error( + `Worksheet ${settings.sheet} not found (workbook has ${workbook.worksheets.length} sheets)`, + ); + } + const rows: string[][] = []; + ws.eachRow((row) => { + const cells: string[] = []; + row.eachCell({ includeEmpty: true }, (cell) => { + cells.push(cell.text); + }); + rows.push(cells); + }); + const csv = Papa.unparse(rows); + return { + buffer: Buffer.from(csv, "utf8"), + filename: `${base}.csv`, + contentType: "text/csv", + }; + } + + // csv -> xlsx: parse CSV rows, build a workbook + const parsed = Papa.parse(input.buffer.toString("utf8"), { + header: false, + skipEmptyLines: true, + }); + if (parsed.errors.length > 0) { + throw new Error(`CSV parse failed: ${parsed.errors[0].message}`); + } + const workbook = new ExcelJS.Workbook(); + const ws = workbook.addWorksheet("Sheet1"); + ws.addRows(parsed.data); + const xlsxBuffer = Buffer.from(await workbook.xlsx.writeBuffer()); + return { + buffer: xlsxBuffer, + filename: `${base}.xlsx`, + contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/csv-json.ts b/apps/api/src/routes/tools/csv-json.ts new file mode 100644 index 00000000..634de9c4 --- /dev/null +++ b/apps/api/src/routes/tools/csv-json.ts @@ -0,0 +1,51 @@ +import type { FastifyInstance } from "fastify"; +import Papa from "papaparse"; +import { z } from "zod"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + pretty: z.boolean().default(true), +}); + +export function registerCsvJson(app: FastifyInstance) { + createToolRoute(app, { + toolId: "csv-json", + settingsSchema, + process: async () => { + throw new Error("csv-json is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const input = ctx.inputs[0]; + const base = input.filename.replace(/\.[^.]+$/, ""); + const lower = input.filename.toLowerCase(); + + if (lower.endsWith(".json")) { + const data: unknown = JSON.parse(input.buffer.toString("utf8")); + if (!Array.isArray(data)) { + throw new Error("JSON input must be an array of objects to convert to CSV"); + } + const csv = Papa.unparse(data as Record[]); + return { + buffer: Buffer.from(csv, "utf8"), + filename: `${base}.csv`, + contentType: "text/csv", + }; + } + + const parsed = Papa.parse>(input.buffer.toString("utf8"), { + header: true, + skipEmptyLines: true, + }); + if (parsed.errors.length > 0) { + throw new Error(`CSV parse failed: ${parsed.errors[0].message}`); + } + const json = JSON.stringify(parsed.data, null, settings.pretty ? 2 : 0); + return { + buffer: Buffer.from(json, "utf8"), + filename: `${base}.json`, + contentType: "application/json", + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/extract-audio.ts b/apps/api/src/routes/tools/extract-audio.ts new file mode 100644 index 00000000..cdf31ebb --- /dev/null +++ b/apps/api/src/routes/tools/extract-audio.ts @@ -0,0 +1,47 @@ +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runMediaTool } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + format: z.enum(["mp3", "wav", "m4a"]).default("mp3"), +}); + +const CONTENT_TYPES: Record = { + mp3: "audio/mpeg", + wav: "audio/wav", + m4a: "audio/mp4", +}; + +export function registerExtractAudio(app: FastifyInstance) { + createToolRoute(app, { + toolId: "extract-audio", + settingsSchema, + process: async () => { + throw new Error("extract-audio is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}.${settings.format}`; + + const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => { + switch (settings.format) { + case "mp3": + return ["-i", inPath, "-vn", "-c:a", "libmp3lame", "-b:a", "192k", out]; + case "wav": + return ["-i", inPath, "-vn", "-c:a", "pcm_s16le", out]; + case "m4a": + return ["-i", inPath, "-vn", "-c:a", "aac", "-b:a", "192k", out]; + default: + return ["-i", inPath, "-vn", "-c:a", "libmp3lame", "-b:a", "192k", out]; + } + }); + return { + scratchPath: outPath, + filename: outName, + contentType: CONTENT_TYPES[settings.format], + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/index.ts b/apps/api/src/routes/tools/index.ts index ae9658aa..8d2d4325 100644 --- a/apps/api/src/routes/tools/index.ts +++ b/apps/api/src/routes/tools/index.ts @@ -16,12 +16,19 @@ import { registerColorize } from "./colorize.js"; import { registerCompare } from "./compare.js"; import { registerCompose } from "./compose.js"; import { registerCompress } from "./compress.js"; +import { registerCompressPdf } from "./compress-pdf.js"; +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 { registerConvertVideo } from "./convert-video.js"; import { registerCrop } from "./crop.js"; +import { registerCsvExcel } from "./csv-excel.js"; +import { registerCsvJson } from "./csv-json.js"; import { registerEditMetadata } from "./edit-metadata.js"; import { registerEnhanceFaces } from "./enhance-faces.js"; import { registerEraseObject } from "./erase-object.js"; +import { registerExtractAudio } from "./extract-audio.js"; import { registerFavicon } from "./favicon.js"; import { registerFindDuplicates } from "./find-duplicates.js"; import { registerGifTools } from "./gif-tools.js"; @@ -30,7 +37,10 @@ import { registerImageEnhancement } from "./image-enhancement.js"; import { registerImageToBase64 } from "./image-to-base64.js"; import { registerImageToPdf } from "./image-to-pdf.js"; import { registerInfo } from "./info.js"; +import { registerJsonXml } from "./json-xml.js"; import { registerMemeGenerator } from "./meme-generator.js"; +import { registerMergePdf } from "./merge-pdf.js"; +import { registerMuteVideo } from "./mute-video.js"; import { registerNoiseRemoval } from "./noise-removal.js"; import { registerOcr } from "./ocr.js"; import { registerOptimizeForWeb } from "./optimize-for-web.js"; @@ -43,18 +53,25 @@ import { registerReplaceColor } from "./replace-color.js"; import { registerResize } from "./resize.js"; import { registerRestorePhoto } from "./restore-photo.js"; import { registerRotate } from "./rotate.js"; +import { registerRotatePdf } from "./rotate-pdf.js"; import { registerSharpening } from "./sharpening.js"; import { registerSmartCrop } from "./smart-crop.js"; import { registerSplit } from "./split.js"; +import { registerSplitCsv } from "./split-csv.js"; +import { registerSplitPdf } from "./split-pdf.js"; 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 { registerTransparencyFixer } from "./transparency-fixer.js"; +import { registerTrimAudio } from "./trim-audio.js"; +import { registerTrimVideo } from "./trim-video.js"; import { registerUpscale } from "./upscale.js"; import { registerVectorize } from "./vectorize.js"; +import { registerVideoToGif } from "./video-to-gif.js"; import { registerWatermarkImage } from "./watermark-image.js"; import { registerWatermarkText } from "./watermark-text.js"; +import { registerWordToPdf } from "./word-to-pdf.js"; /** * Registry that imports and registers all tool routes. @@ -139,6 +156,31 @@ export async function registerToolRoutes(app: FastifyInstance): Promise { { id: "replace-color", register: registerReplaceColor }, { id: "color-blindness", register: registerColorBlindness }, + // Video + { id: "convert-video", register: registerConvertVideo }, + { id: "compress-video", register: registerCompressVideo }, + { id: "trim-video", register: registerTrimVideo }, + { id: "mute-video", register: registerMuteVideo }, + { id: "video-to-gif", register: registerVideoToGif }, + + // Audio + { id: "convert-audio", register: registerConvertAudio }, + { id: "trim-audio", register: registerTrimAudio }, + { id: "extract-audio", register: registerExtractAudio }, + + // PDF & Documents + { id: "merge-pdf", register: registerMergePdf }, + { id: "split-pdf", register: registerSplitPdf }, + { id: "compress-pdf", register: registerCompressPdf }, + { id: "rotate-pdf", register: registerRotatePdf }, + { id: "word-to-pdf", register: registerWordToPdf }, + + // Data Files + { id: "csv-excel", register: registerCsvExcel }, + { id: "csv-json", register: registerCsvJson }, + { id: "json-xml", register: registerJsonXml }, + { id: "split-csv", register: registerSplitCsv }, + // AI Tools { id: "remove-background", register: registerRemoveBackground }, { id: "upscale", register: registerUpscale }, diff --git a/apps/api/src/routes/tools/json-xml.ts b/apps/api/src/routes/tools/json-xml.ts new file mode 100644 index 00000000..957b5a69 --- /dev/null +++ b/apps/api/src/routes/tools/json-xml.ts @@ -0,0 +1,54 @@ +import { XMLBuilder, XMLParser } from "fast-xml-parser"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + pretty: z.boolean().default(true), +}); + +export function registerJsonXml(app: FastifyInstance) { + createToolRoute(app, { + toolId: "json-xml", + settingsSchema, + process: async () => { + throw new Error("json-xml is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const input = ctx.inputs[0]; + const base = input.filename.replace(/\.[^.]+$/, ""); + const lower = input.filename.toLowerCase(); + const text = input.buffer.toString("utf8"); + + if (lower.endsWith(".xml")) { + // xml -> json + const parser = new XMLParser({ ignoreAttributes: false }); + const parsed = parser.parse(text); + const json = JSON.stringify(parsed, null, settings.pretty ? 2 : 0); + return { + buffer: Buffer.from(json, "utf8"), + filename: `${base}.json`, + contentType: "application/json", + }; + } + + // json -> xml + const data: unknown = JSON.parse(text); + // Wrap in a root element when the top level is an array or has + // multiple keys, so the XML is well-formed with a single root. + const wrapped = + Array.isArray(data) || + (typeof data === "object" && data !== null && Object.keys(data).length !== 1) + ? { root: data } + : data; + const builder = new XMLBuilder({ format: settings.pretty, ignoreAttributes: false }); + const xml = builder.build(wrapped) as string; + return { + buffer: Buffer.from(xml, "utf8"), + filename: `${base}.xml`, + contentType: "application/xml", + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/merge-pdf.ts b/apps/api/src/routes/tools/merge-pdf.ts new file mode 100644 index 00000000..7de36f29 --- /dev/null +++ b/apps/api/src/routes/tools/merge-pdf.ts @@ -0,0 +1,35 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { qpdfMerge } 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 registerMergePdf(app: FastifyInstance) { + createToolRoute(app, { + toolId: "merge-pdf", + maxInputs: 20, + settingsSchema, + process: async () => { + throw new Error("merge-pdf is v2-only"); + }, + processV2: async (ctx) => { + if (ctx.inputs.length < 2) { + throw new Error("Merging needs at least two PDFs"); + } + ctx.report(10, "Staging"); + const paths: string[] = []; + for (let i = 0; i < ctx.inputs.length; i++) { + const p = join(ctx.scratchDir, `in-${i}.pdf`); + await writeFile(p, ctx.inputs[i].buffer); + paths.push(p); + } + const outPath = join(ctx.scratchDir, "merged.pdf"); + ctx.report(40, "Merging"); + await qpdfMerge(paths, outPath); + return { scratchPath: outPath, filename: "merged.pdf", contentType: "application/pdf" }; + }, + }); +} diff --git a/apps/api/src/routes/tools/mute-video.ts b/apps/api/src/routes/tools/mute-video.ts new file mode 100644 index 00000000..a91928ef --- /dev/null +++ b/apps/api/src/routes/tools/mute-video.ts @@ -0,0 +1,28 @@ +import { extname } from "node:path"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runMediaTool, videoContentType } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({}); + +export function registerMuteVideo(app: FastifyInstance) { + createToolRoute(app, { + toolId: "mute-video", + settingsSchema, + process: async () => { + throw new Error("mute-video is v2-only"); + }, + processV2: async (ctx) => { + const origExt = extname(ctx.inputs[0].filename) || ".mp4"; + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_muted${origExt}`; + const contentType = videoContentType(origExt); + + const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => { + return ["-i", inPath, "-c", "copy", "-an", out]; + }); + return { scratchPath: outPath, filename: outName, contentType }; + }, + }); +} diff --git a/apps/api/src/routes/tools/rotate-pdf.ts b/apps/api/src/routes/tools/rotate-pdf.ts new file mode 100644 index 00000000..435b1a42 --- /dev/null +++ b/apps/api/src/routes/tools/rotate-pdf.ts @@ -0,0 +1,43 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { qpdfRotate } from "@snapotter/doc-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + angle: z.union([z.literal(90), z.literal(180), z.literal(270)]).default(90), + range: z + .string() + .max(200) + .regex(/^[0-9rz][0-9rz,-]*$/i, "Invalid page range") + .default("1-z"), +}); + +export function registerRotatePdf(app: FastifyInstance) { + createToolRoute(app, { + toolId: "rotate-pdf", + settingsSchema, + process: async () => { + throw new Error("rotate-pdf is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + 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}_rotated.pdf`); + ctx.report(10, "Rotating"); + await qpdfRotate(inPath, settings.angle, settings.range, outPath); + ctx.report(90, "Done"); + + return { + scratchPath: outPath, + filename: `${base}_rotated.pdf`, + contentType: "application/pdf", + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/split-csv.ts b/apps/api/src/routes/tools/split-csv.ts new file mode 100644 index 00000000..94656c88 --- /dev/null +++ b/apps/api/src/routes/tools/split-csv.ts @@ -0,0 +1,82 @@ +import { createWriteStream } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import archiver from "archiver"; +import type { FastifyInstance } from "fastify"; +import Papa from "papaparse"; +import { z } from "zod"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + rowsPerFile: z.number().int().min(1).max(1_000_000).default(1000), + keepHeader: z.boolean().default(true), +}); + +export function registerSplitCsv(app: FastifyInstance) { + createToolRoute(app, { + toolId: "split-csv", + settingsSchema, + process: async () => { + throw new Error("split-csv is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const input = ctx.inputs[0]; + const base = input.filename.replace(/\.[^.]+$/, ""); + + const parsed = Papa.parse(input.buffer.toString("utf8"), { + header: false, + skipEmptyLines: true, + }); + if (parsed.errors.length > 0) { + throw new Error(`CSV parse failed: ${parsed.errors[0].message}`); + } + const allRows = parsed.data; + if (allRows.length === 0) { + throw new Error("CSV file is empty"); + } + + const header = settings.keepHeader ? allRows[0] : null; + const dataRows = settings.keepHeader ? allRows.slice(1) : allRows; + + // Chunk data rows + const chunks: string[][][] = []; + for (let i = 0; i < dataRows.length; i += settings.rowsPerFile) { + chunks.push(dataRows.slice(i, i + settings.rowsPerFile)); + } + + // Write part files to scratch + const partPaths: string[] = []; + for (let i = 0; i < chunks.length; i++) { + const rows = header ? [header, ...chunks[i]] : chunks[i]; + const csv = Papa.unparse(rows); + const partPath = join(ctx.scratchDir, `part-${i + 1}.csv`); + await writeFile(partPath, csv, "utf8"); + partPaths.push(partPath); + const pct = Math.min(80, 10 + Math.round(((i + 1) / chunks.length) * 70)); + ctx.report(pct, `Writing part ${i + 1} of ${chunks.length}`); + } + + // Zip the parts (mirrors split-pdf archiver pattern) + ctx.report(85, "Creating archive"); + const zipPath = join(ctx.scratchDir, `${base}_parts.zip`); + await new Promise((resolve, reject) => { + const output = createWriteStream(zipPath); + const archive = archiver("zip", { zlib: { level: 5 } }); + output.on("close", () => resolve()); + archive.on("error", (err: Error) => reject(err)); + archive.pipe(output); + for (let i = 0; i < partPaths.length; i++) { + archive.file(partPaths[i], { name: `part-${i + 1}.csv` }); + } + void archive.finalize(); + }); + + return { + scratchPath: zipPath, + filename: `${base}_parts.zip`, + contentType: "application/zip", + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/split-pdf.ts b/apps/api/src/routes/tools/split-pdf.ts new file mode 100644 index 00000000..799265f4 --- /dev/null +++ b/apps/api/src/routes/tools/split-pdf.ts @@ -0,0 +1,89 @@ +import { createWriteStream } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { qpdfPageCount, qpdfSplitRanges } from "@snapotter/doc-engine"; +import archiver from "archiver"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z + .object({ + mode: z.enum(["range", "every"]).default("range"), + range: z + .string() + .max(200) + .regex(/^[0-9rz][0-9rz,-]*$/i, "Invalid page range") + .optional(), + everyN: z.number().int().min(1).max(500).optional(), + }) + .refine( + (s) => { + if (s.mode === "range") return !!s.range; + return s.everyN !== undefined; + }, + { message: "range required for range mode; everyN required for every mode" }, + ); + +export function registerSplitPdf(app: FastifyInstance) { + createToolRoute(app, { + toolId: "split-pdf", + settingsSchema, + process: async () => { + throw new Error("split-pdf is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + 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); + + if (settings.mode === "range") { + const outPath = join(ctx.scratchDir, `${base}_pages.pdf`); + ctx.report(20, "Extracting pages"); + await qpdfSplitRanges(inPath, settings.range ?? "", outPath); + return { + scratchPath: outPath, + filename: `${base}_pages.pdf`, + contentType: "application/pdf", + }; + } + + // mode === "every": split into chunks of everyN pages + const totalPages = await qpdfPageCount(inPath); + const n = settings.everyN ?? 1; + const parts: string[] = []; + for (let start = 1; start <= totalPages; start += n) { + const end = Math.min(start + n - 1, totalPages); + const partPath = join(ctx.scratchDir, `part-${parts.length + 1}.pdf`); + const range = `${start}-${end}`; + await qpdfSplitRanges(inPath, range, partPath); + parts.push(partPath); + const pct = Math.min(80, 10 + Math.round((start / totalPages) * 70)); + ctx.report(pct, `Splitting part ${parts.length}`); + } + + // Zip the parts + ctx.report(85, "Creating archive"); + const zipPath = join(ctx.scratchDir, `${base}_parts.zip`); + await new Promise((resolve, reject) => { + const output = createWriteStream(zipPath); + const archive = archiver("zip", { zlib: { level: 5 } }); + output.on("close", () => resolve()); + archive.on("error", (err: Error) => reject(err)); + archive.pipe(output); + for (let i = 0; i < parts.length; i++) { + archive.file(parts[i], { name: `part-${i + 1}.pdf` }); + } + void archive.finalize(); + }); + + return { + scratchPath: zipPath, + filename: `${base}_parts.zip`, + contentType: "application/zip", + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/trim-audio.ts b/apps/api/src/routes/tools/trim-audio.ts new file mode 100644 index 00000000..c42d88c3 --- /dev/null +++ b/apps/api/src/routes/tools/trim-audio.ts @@ -0,0 +1,45 @@ +import { extname } from "node:path"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { audioContentType, runMediaTool } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z + .object({ + startS: z.number().min(0).default(0), + endS: z.number().positive(), + }) + .refine((s) => s.endS > s.startS, { message: "End must be after start" }); + +export function registerTrimAudio(app: FastifyInstance) { + createToolRoute(app, { + toolId: "trim-audio", + settingsSchema, + process: async () => { + throw new Error("trim-audio is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const origExt = extname(ctx.inputs[0].filename) || ".mp3"; + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_trimmed${origExt}`; + const contentType = audioContentType(origExt); + + const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => { + // Fast seek with stream-copy for audio + return [ + "-ss", + String(settings.startS), + "-to", + String(settings.endS), + "-i", + inPath, + "-c", + "copy", + out, + ]; + }); + return { scratchPath: outPath, filename: outName, contentType }; + }, + }); +} diff --git a/apps/api/src/routes/tools/trim-video.ts b/apps/api/src/routes/tools/trim-video.ts new file mode 100644 index 00000000..a8d9e357 --- /dev/null +++ b/apps/api/src/routes/tools/trim-video.ts @@ -0,0 +1,70 @@ +import { extname } from "node:path"; +import { resolveEncoder } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runMediaTool, videoContentType } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z + .object({ + startS: z.number().min(0).default(0), + endS: z.number().positive(), + precise: z.boolean().default(false), + }) + .refine((s) => s.endS > s.startS, { message: "End must be after start" }); + +export function registerTrimVideo(app: FastifyInstance) { + createToolRoute(app, { + toolId: "trim-video", + settingsSchema, + process: async () => { + throw new Error("trim-video is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const origExt = extname(ctx.inputs[0].filename) || ".mp4"; + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_trimmed${origExt}`; + const contentType = videoContentType(origExt); + + const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => { + if (settings.precise) { + return [ + "-i", + inPath, + "-ss", + String(settings.startS), + "-to", + String(settings.endS), + "-c:v", + resolveEncoder("h264"), + "-crf", + "20", + "-preset", + "medium", + "-pix_fmt", + "yuv420p", + "-c:a", + resolveEncoder("aac"), + out, + ]; + } + // Fast seek: -ss before -i for stream-copy + return [ + "-ss", + String(settings.startS), + "-to", + String(settings.endS), + "-i", + inPath, + "-c", + "copy", + "-avoid_negative_ts", + "make_zero", + out, + ]; + }); + return { scratchPath: outPath, filename: outName, contentType }; + }, + }); +} diff --git a/apps/api/src/routes/tools/video-to-gif.ts b/apps/api/src/routes/tools/video-to-gif.ts new file mode 100644 index 00000000..d6f8b31b --- /dev/null +++ b/apps/api/src/routes/tools/video-to-gif.ts @@ -0,0 +1,41 @@ +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runMediaTool } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + fps: z.number().int().min(1).max(30).default(12), + width: z.number().int().min(64).max(1280).default(480), + startS: z.number().min(0).default(0), + durationS: z.number().positive().max(60).default(5), +}); + +export function registerVideoToGif(app: FastifyInstance) { + createToolRoute(app, { + toolId: "video-to-gif", + settingsSchema, + process: async () => { + throw new Error("video-to-gif is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}.gif`; + + const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => { + return [ + "-ss", + String(settings.startS), + "-t", + String(settings.durationS), + "-i", + inPath, + "-vf", + `fps=${settings.fps},scale=${settings.width}:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse`, + out, + ]; + }); + return { scratchPath: outPath, filename: outName, contentType: "image/gif" }; + }, + }); +} diff --git a/apps/api/src/routes/tools/word-to-pdf.ts b/apps/api/src/routes/tools/word-to-pdf.ts new file mode 100644 index 00000000..c9d33a02 --- /dev/null +++ b/apps/api/src/routes/tools/word-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 registerWordToPdf(app: FastifyInstance) { + createToolRoute(app, { + toolId: "word-to-pdf", + settingsSchema, + process: async () => { + throw new Error("word-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. .docx vs .odt vs .rtf). + 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/web/src/components/tools/compress-pdf-settings.tsx b/apps/web/src/components/tools/compress-pdf-settings.tsx new file mode 100644 index 00000000..ada4854a --- /dev/null +++ b/apps/web/src/components/tools/compress-pdf-settings.tsx @@ -0,0 +1,73 @@ +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 Preset = "screen" | "ebook" | "printer"; + +export function CompressPdfSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["compress-pdf"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("compress-pdf"); + + const [preset, setPreset] = useState("ebook"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { preset }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + +
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/compress-video-settings.tsx b/apps/web/src/components/tools/compress-video-settings.tsx new file mode 100644 index 00000000..c4dbccc7 --- /dev/null +++ b/apps/web/src/components/tools/compress-video-settings.tsx @@ -0,0 +1,92 @@ +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 Quality = "light" | "balanced" | "strong"; +type Resolution = "original" | "1080p" | "720p" | "480p"; + +export function CompressVideoSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["compress-video"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("compress-video"); + + const [quality, setQuality] = useState("balanced"); + const [resolution, setResolution] = useState("original"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { quality, resolution }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + +
+ +
+ + +
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/convert-audio-settings.tsx b/apps/web/src/components/tools/convert-audio-settings.tsx new file mode 100644 index 00000000..0843b264 --- /dev/null +++ b/apps/web/src/components/tools/convert-audio-settings.tsx @@ -0,0 +1,96 @@ +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 AudioFormat = "mp3" | "wav" | "ogg" | "flac" | "m4a"; + +const BITRATE_OPTIONS = [96, 128, 192, 256, 320] as const; + +export function ConvertAudioSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["convert-audio"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("convert-audio"); + + const [outFormat, setOutFormat] = useState("mp3"); + const [bitrateKbps, setBitrateKbps] = useState(192); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { format: outFormat, bitrateKbps }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + +
+ +
+ + +
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/convert-video-settings.tsx b/apps/web/src/components/tools/convert-video-settings.tsx new file mode 100644 index 00000000..aa761ef9 --- /dev/null +++ b/apps/web/src/components/tools/convert-video-settings.tsx @@ -0,0 +1,91 @@ +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 VideoFormat = "mp4" | "mov" | "webm"; +type Quality = "high" | "balanced" | "small"; + +export function ConvertVideoSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["convert-video"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("convert-video"); + + const [outFormat, setOutFormat] = useState("mp4"); + const [quality, setQuality] = useState("balanced"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { format: outFormat, quality }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + +
+ +
+ + +
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/csv-excel-settings.tsx b/apps/web/src/components/tools/csv-excel-settings.tsx new file mode 100644 index 00000000..d5262d74 --- /dev/null +++ b/apps/web/src/components/tools/csv-excel-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"; + +export function CsvExcelSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["csv-excel"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("csv-excel"); + + const [sheet, setSheet] = useState(1); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const firstFileName = files[0]?.name ?? ""; + const isXlsx = firstFileName.toLowerCase().endsWith(".xlsx"); + + const handleProcess = () => { + const settings = isXlsx ? { sheet } : {}; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+ {isXlsx && ( +
+ + setSheet(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ )} + + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/csv-json-settings.tsx b/apps/web/src/components/tools/csv-json-settings.tsx new file mode 100644 index 00000000..71c430b9 --- /dev/null +++ b/apps/web/src/components/tools/csv-json-settings.tsx @@ -0,0 +1,65 @@ +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"; + +export function CsvJsonSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["csv-json"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("csv-json"); + + const [pretty, setPretty] = useState(true); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { pretty }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+ + + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/extract-audio-settings.tsx b/apps/web/src/components/tools/extract-audio-settings.tsx new file mode 100644 index 00000000..fc63a08b --- /dev/null +++ b/apps/web/src/components/tools/extract-audio-settings.tsx @@ -0,0 +1,73 @@ +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 AudioFormat = "mp3" | "wav" | "m4a"; + +export function ExtractAudioSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["extract-audio"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("extract-audio"); + + const [outFormat, setOutFormat] = useState("mp3"); + + 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/json-xml-settings.tsx b/apps/web/src/components/tools/json-xml-settings.tsx new file mode 100644 index 00000000..5fa2687d --- /dev/null +++ b/apps/web/src/components/tools/json-xml-settings.tsx @@ -0,0 +1,65 @@ +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"; + +export function JsonXmlSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["json-xml"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("json-xml"); + + const [pretty, setPretty] = useState(true); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { pretty }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+ + + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/merge-pdf-settings.tsx b/apps/web/src/components/tools/merge-pdf-settings.tsx new file mode 100644 index 00000000..f02cafc7 --- /dev/null +++ b/apps/web/src/components/tools/merge-pdf-settings.tsx @@ -0,0 +1,51 @@ +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 MergePdfSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["merge-pdf"]; + const { files } = useFileStore(); + const { processFiles, processing, error, progress } = useToolProcessor("merge-pdf"); + + const hasEnough = files.length >= 2; + + const handleProcess = () => { + processFiles(files, {}); + }; + + return ( +
+ {files.length > 0 && ( +

+ {hasEnough ? format(s.orderHint, { count: files.length }) : s.needTwo} +

+ )} + + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/mute-video-settings.tsx b/apps/web/src/components/tools/mute-video-settings.tsx new file mode 100644 index 00000000..365363bf --- /dev/null +++ b/apps/web/src/components/tools/mute-video-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 MuteVideoSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["mute-video"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("mute-video"); + + 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/rotate-pdf-settings.tsx b/apps/web/src/components/tools/rotate-pdf-settings.tsx new file mode 100644 index 00000000..5cddbb0d --- /dev/null +++ b/apps/web/src/components/tools/rotate-pdf-settings.tsx @@ -0,0 +1,88 @@ +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 Angle = 90 | 180 | 270; + +export function RotatePdfSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["rotate-pdf"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("rotate-pdf"); + + const [angle, setAngle] = useState(90); + const [range, setRange] = useState("1-z"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { angle, range }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + +
+ +
+ + setRange(e.target.value)} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +

{s.rangeHint}

+
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/split-csv-settings.tsx b/apps/web/src/components/tools/split-csv-settings.tsx new file mode 100644 index 00000000..1df24cdd --- /dev/null +++ b/apps/web/src/components/tools/split-csv-settings.tsx @@ -0,0 +1,81 @@ +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"; + +export function SplitCsvSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["split-csv"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("split-csv"); + + const [rowsPerFile, setRowsPerFile] = useState(1000); + const [keepHeader, setKeepHeader] = useState(true); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { rowsPerFile, keepHeader }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + setRowsPerFile(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ + + + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/split-pdf-settings.tsx b/apps/web/src/components/tools/split-pdf-settings.tsx new file mode 100644 index 00000000..a915ac4e --- /dev/null +++ b/apps/web/src/components/tools/split-pdf-settings.tsx @@ -0,0 +1,108 @@ +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 SplitMode = "range" | "every"; + +export function SplitPdfSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["split-pdf"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("split-pdf"); + + const [mode, setMode] = useState("range"); + const [range, setRange] = useState("1-3,5"); + const [everyN, setEveryN] = useState(1); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = mode === "range" ? { mode, range } : { mode, everyN }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + +
+ + {mode === "range" && ( +
+ + setRange(e.target.value)} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +

{s.rangeHint}

+
+ )} + + {mode === "every" && ( +
+ + setEveryN(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ )} + + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/trim-audio-settings.tsx b/apps/web/src/components/tools/trim-audio-settings.tsx new file mode 100644 index 00000000..24f44549 --- /dev/null +++ b/apps/web/src/components/tools/trim-audio-settings.tsx @@ -0,0 +1,90 @@ +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"; + +export function TrimAudioSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["trim-audio"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("trim-audio"); + + const [startS, setStartS] = useState(0); + const [endS, setEndS] = useState(10); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + const rangeValid = endS > startS; + + const handleProcess = () => { + const settings = { startS, endS }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + setStartS(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + setEndS(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> + {!rangeValid && ( +

End time must be after start time

+ )} +
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/trim-video-settings.tsx b/apps/web/src/components/tools/trim-video-settings.tsx new file mode 100644 index 00000000..d5acffc7 --- /dev/null +++ b/apps/web/src/components/tools/trim-video-settings.tsx @@ -0,0 +1,101 @@ +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"; + +export function TrimVideoSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["trim-video"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("trim-video"); + + const [startS, setStartS] = useState(0); + const [endS, setEndS] = useState(10); + const [precise, setPrecise] = useState(false); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + const rangeValid = endS > startS; + + const handleProcess = () => { + const settings = { startS, endS, precise }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + setStartS(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + setEndS(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> + {!rangeValid && ( +

End time must be after start time

+ )} +
+ + + + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/video-to-gif-settings.tsx b/apps/web/src/components/tools/video-to-gif-settings.tsx new file mode 100644 index 00000000..21943f48 --- /dev/null +++ b/apps/web/src/components/tools/video-to-gif-settings.tsx @@ -0,0 +1,121 @@ +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"; + +export function VideoToGifSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["video-to-gif"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("video-to-gif"); + + const [fps, setFps] = useState(12); + const [width, setWidth] = useState(480); + const [startS, setStartS] = useState(0); + const [durationS, setDurationS] = useState(5); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { fps, width, startS, durationS }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + setFps(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + setWidth(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + setStartS(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + setDurationS(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/word-to-pdf-settings.tsx b/apps/web/src/components/tools/word-to-pdf-settings.tsx new file mode 100644 index 00000000..ae2286cf --- /dev/null +++ b/apps/web/src/components/tools/word-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 WordToPdfSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["word-to-pdf"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("word-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/hooks/use-tool-processor.ts b/apps/web/src/hooks/use-tool-processor.ts index c1e51414..a1059757 100644 --- a/apps/web/src/hooks/use-tool-processor.ts +++ b/apps/web/src/hooks/use-tool-processor.ts @@ -2,6 +2,7 @@ import { PYTHON_SIDECAR_TOOLS, TOOLS } from "@snapotter/shared"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "@/contexts/i18n-context"; import { formatHeaders, parseApiError } from "@/lib/api"; +import { MULTI_FILE_TOOLS } from "@/lib/tool-display-modes"; import { generateId } from "@/lib/utils"; import { useFileStore } from "@/stores/file-store"; @@ -343,7 +344,11 @@ export function useToolProcessor(toolId: string) { delete cleanSettings._bgImageFile; const formData = new FormData(); - formData.append("file", files[capturedIndex] ?? files[0]); + if (MULTI_FILE_TOOLS.has(toolId) && files.length > 1) { + for (const f of files) formData.append("file", f); + } else { + formData.append("file", files[capturedIndex] ?? files[0]); + } formData.append("settings", JSON.stringify(cleanSettings)); if (bgImageFile) { formData.append("backgroundImage", bgImageFile); diff --git a/apps/web/src/lib/icon-map.ts b/apps/web/src/lib/icon-map.ts index 614ccd10..41a74875 100644 --- a/apps/web/src/lib/icon-map.ts +++ b/apps/web/src/lib/icon-map.ts @@ -3,9 +3,11 @@ import { AppWindow, AudioLines, BookImage, + Braces, CheckCircle2, Code, Columns2, + Combine, Copy, Crop, Crosshair, @@ -15,11 +17,13 @@ import { Eye, EyeOff, FileArchive, + FileAudio, FileImage, FileOutput, FilePen, FileText, FileType, + FileVideo, Film, Focus, Frame, @@ -44,17 +48,21 @@ import { ScanFace, ScanLine, ScanText, + Scissors, ShieldCheck, ShieldOff, SlidersHorizontal, Sparkles, + Split, Stamp, Star, + Table, TextCursorInput, Type, Undo2, UserCheck, Video, + VolumeX, Wand, Wrench, Zap, @@ -67,9 +75,11 @@ export const ICON_MAP: Record = { AppWindow, AudioLines, BookImage, + Braces, CheckCircle2, Code, Columns2, + Combine, Copy, Crop, Crosshair, @@ -79,9 +89,11 @@ export const ICON_MAP: Record = { Eye, EyeOff, FileArchive, + FileAudio, FilePen, FileImage, FileOutput, + FileVideo, FileText, FileType, Film, @@ -104,6 +116,7 @@ export const ICON_MAP: Record = { QrCode, RotateCw, Scaling, + Scissors, ScanEye, ScanFace, ScanLine, @@ -112,13 +125,16 @@ export const ICON_MAP: Record = { ShieldOff, SlidersHorizontal, Sparkles, + Split, Stamp, Star, + Table, TextCursorInput, Type, Undo2, UserCheck, Video, + VolumeX, Wand, Wrench, Zap, diff --git a/apps/web/src/lib/tool-display-modes.ts b/apps/web/src/lib/tool-display-modes.ts index b280aa38..d0f33a17 100644 --- a/apps/web/src/lib/tool-display-modes.ts +++ b/apps/web/src/lib/tool-display-modes.ts @@ -90,4 +90,35 @@ export const TOOL_DISPLAY_MODES: Record = { "transparency-fixer": "before-after", "content-aware-resize": "side-by-side", "ai-canvas-expand": "before-after", + + // Video tools + "convert-video": "media-player", + "compress-video": "media-player", + "trim-video": "media-player", + "mute-video": "media-player", + "video-to-gif": "side-by-side", + + // Audio tools + "convert-audio": "media-player", + "trim-audio": "media-player", + "extract-audio": "media-player", + + // PDF & Document tools + "merge-pdf": "document", + "split-pdf": "no-comparison", + "compress-pdf": "document", + "rotate-pdf": "document", + "word-to-pdf": "document", + + // Data tools + "csv-excel": "no-comparison", + "csv-json": "no-comparison", + "json-xml": "no-comparison", + "split-csv": "no-comparison", }; + +/** + * Tools whose selected files all post in ONE request as repeated "file" parts. + * Consumed by use-tool-processor; backend routes declare maxInputs. + */ +export const MULTI_FILE_TOOLS: ReadonlySet = new Set(["merge-pdf"]); diff --git a/apps/web/src/lib/tool-registry.tsx b/apps/web/src/lib/tool-registry.tsx index 06b836b9..87096353 100644 --- a/apps/web/src/lib/tool-registry.tsx +++ b/apps/web/src/lib/tool-registry.tsx @@ -4,6 +4,8 @@ * Maps each toolId to its settings component, display mode, and capabilities. * Adding a new tool means adding one entry here instead of editing a 750-line file. */ + +import { AUDIO_INPUTS, VIDEO_INPUTS } from "@snapotter/shared"; import type React from "react"; import { lazy } from "react"; import type { Crop } from "react-image-crop"; @@ -342,6 +344,91 @@ const ColorBlindnessSettings = lazy(() => default: m.ColorBlindnessSettings, })), ); +const ConvertVideoSettings = lazy(() => + import("@/components/tools/convert-video-settings").then((m) => ({ + default: m.ConvertVideoSettings, + })), +); +const CompressVideoSettings = lazy(() => + import("@/components/tools/compress-video-settings").then((m) => ({ + default: m.CompressVideoSettings, + })), +); +const TrimVideoSettings = lazy(() => + import("@/components/tools/trim-video-settings").then((m) => ({ + default: m.TrimVideoSettings, + })), +); +const MuteVideoSettings = lazy(() => + import("@/components/tools/mute-video-settings").then((m) => ({ + default: m.MuteVideoSettings, + })), +); +const VideoToGifSettings = lazy(() => + import("@/components/tools/video-to-gif-settings").then((m) => ({ + default: m.VideoToGifSettings, + })), +); +const ConvertAudioSettings = lazy(() => + import("@/components/tools/convert-audio-settings").then((m) => ({ + default: m.ConvertAudioSettings, + })), +); +const TrimAudioSettings = lazy(() => + import("@/components/tools/trim-audio-settings").then((m) => ({ + default: m.TrimAudioSettings, + })), +); +const ExtractAudioSettings = lazy(() => + import("@/components/tools/extract-audio-settings").then((m) => ({ + default: m.ExtractAudioSettings, + })), +); +const MergePdfSettings = lazy(() => + import("@/components/tools/merge-pdf-settings").then((m) => ({ + default: m.MergePdfSettings, + })), +); +const SplitPdfSettings = lazy(() => + import("@/components/tools/split-pdf-settings").then((m) => ({ + default: m.SplitPdfSettings, + })), +); +const CompressPdfSettings = lazy(() => + import("@/components/tools/compress-pdf-settings").then((m) => ({ + default: m.CompressPdfSettings, + })), +); +const RotatePdfSettings = lazy(() => + import("@/components/tools/rotate-pdf-settings").then((m) => ({ + default: m.RotatePdfSettings, + })), +); +const WordToPdfSettings = lazy(() => + import("@/components/tools/word-to-pdf-settings").then((m) => ({ + default: m.WordToPdfSettings, + })), +); +const CsvExcelSettings = lazy(() => + import("@/components/tools/csv-excel-settings").then((m) => ({ + default: m.CsvExcelSettings, + })), +); +const CsvJsonSettings = lazy(() => + import("@/components/tools/csv-json-settings").then((m) => ({ + default: m.CsvJsonSettings, + })), +); +const JsonXmlSettings = lazy(() => + import("@/components/tools/json-xml-settings").then((m) => ({ + default: m.JsonXmlSettings, + })), +); +const SplitCsvSettings = lazy(() => + import("@/components/tools/split-csv-settings").then((m) => ({ + default: m.SplitCsvSettings, + })), +); // ── Color tool wrapper ───────────────────────────────────────────── // Color tools share a single component but differ by toolId. @@ -428,7 +515,10 @@ const ENTRY_CONFIG: ReadonlyArray<[string, RegistryEntryConfig]> = [ ["favicon", { Settings: FaviconSettings }], ["image-to-pdf", { Settings: ImageToPdfSettings }], ["optimize-for-web", { Settings: OptimizeForWebSettings }], - ["pdf-to-image", { Settings: PdfToImageSettings, ResultsPanel: PdfToImagePreview }], + [ + "pdf-to-image", + { accept: ".pdf", Settings: PdfToImageSettings, ResultsPanel: PdfToImagePreview }, + ], // Adjustments extra ["replace-color", { Settings: ReplaceColorSettings }], @@ -451,6 +541,31 @@ const ENTRY_CONFIG: ReadonlyArray<[string, RegistryEntryConfig]> = [ ["transparency-fixer", { Settings: TransparencyFixerSettings }], ["content-aware-resize", { Settings: ContentAwareResizeSettings }], ["ai-canvas-expand", { Settings: AiCanvasExpandSettings }], + + // Video tools + ["convert-video", { accept: VIDEO_INPUTS.join(","), Settings: ConvertVideoSettings }], + ["compress-video", { accept: VIDEO_INPUTS.join(","), Settings: CompressVideoSettings }], + ["trim-video", { accept: VIDEO_INPUTS.join(","), Settings: TrimVideoSettings }], + ["mute-video", { accept: VIDEO_INPUTS.join(","), Settings: MuteVideoSettings }], + ["video-to-gif", { accept: VIDEO_INPUTS.join(","), Settings: VideoToGifSettings }], + + // Audio tools + ["convert-audio", { accept: AUDIO_INPUTS.join(","), Settings: ConvertAudioSettings }], + ["trim-audio", { accept: AUDIO_INPUTS.join(","), Settings: TrimAudioSettings }], + ["extract-audio", { accept: VIDEO_INPUTS.join(","), Settings: ExtractAudioSettings }], + + // PDF & Document tools + ["merge-pdf", { accept: ".pdf", Settings: MergePdfSettings }], + ["split-pdf", { accept: ".pdf", Settings: SplitPdfSettings }], + ["compress-pdf", { accept: ".pdf", Settings: CompressPdfSettings }], + ["rotate-pdf", { accept: ".pdf", Settings: RotatePdfSettings }], + ["word-to-pdf", { accept: ".docx,.doc,.odt,.rtf,.txt", Settings: WordToPdfSettings }], + + // Data tools + ["csv-excel", { accept: ".csv,.xlsx", Settings: CsvExcelSettings }], + ["csv-json", { accept: ".csv,.json", Settings: CsvJsonSettings }], + ["json-xml", { accept: ".json,.xml", Settings: JsonXmlSettings }], + ["split-csv", { accept: ".csv", Settings: SplitCsvSettings }], ]; export const toolRegistry = new Map( diff --git a/packages/doc-engine/src/binaries.ts b/packages/doc-engine/src/binaries.ts index 7edd6b89..1df06f93 100644 --- a/packages/doc-engine/src/binaries.ts +++ b/packages/doc-engine/src/binaries.ts @@ -31,3 +31,6 @@ export function qpdfAvailable(): boolean { export function sofficeAvailable(): boolean { return resolveSoffice() !== null; } +export function gsAvailable(): boolean { + return resolveGs() !== null; +} diff --git a/packages/doc-engine/src/ghostscript.ts b/packages/doc-engine/src/ghostscript.ts new file mode 100644 index 00000000..43952899 --- /dev/null +++ b/packages/doc-engine/src/ghostscript.ts @@ -0,0 +1,55 @@ +import { spawn } from "node:child_process"; +import { resolveGs } from "./binaries.js"; + +export type PdfCompressionPreset = "screen" | "ebook" | "printer"; + +/** Ghostscript re-distillation with a quality preset; 120s hard kill. */ +export async function gsCompressPdf( + inputPath: string, + outPath: string, + preset: PdfCompressionPreset, +): Promise { + const bin = resolveGs(); + if (!bin) throw new Error("gs binary not found (set GS_PATH or install ghostscript)"); + await new Promise((resolvePromise, reject) => { + const child = spawn( + bin, + [ + "-dSAFER", + "-dBATCH", + "-dNOPAUSE", + "-dQUIET", + "-sDEVICE=pdfwrite", + `-dPDFSETTINGS=/${preset}`, + "-dCompatibilityLevel=1.6", + `-sOutputFile=${outPath}`, + inputPath, + ], + { stdio: ["ignore", "ignore", "pipe"] }, + ); + let err = ""; + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill("SIGKILL"); + reject(new Error("ghostscript timed out after 120s")); + }, 120_000); + 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(`gs exited ${code ?? signal}: ${err.slice(-1000)}`)); + }); + }); +} diff --git a/packages/doc-engine/src/index.ts b/packages/doc-engine/src/index.ts index 6cc7b37e..297c0aa8 100644 --- a/packages/doc-engine/src/index.ts +++ b/packages/doc-engine/src/index.ts @@ -1,10 +1,13 @@ export { + gsAvailable, qpdfAvailable, resolveGs, resolveQpdf, resolveSoffice, sofficeAvailable, } from "./binaries.js"; +export { gsCompressPdf, type PdfCompressionPreset } from "./ghostscript.js"; export { type ConvertOptions, convertDocument } from "./libreoffice.js"; +export { assertValidRange, qpdfMerge, qpdfRotate, qpdfSplitRanges } from "./pdf-ops.js"; export { pdfPageCountPy } from "./python-docs.js"; export { qpdfCheck, qpdfPageCount } from "./qpdf.js"; diff --git a/packages/doc-engine/src/pdf-ops.ts b/packages/doc-engine/src/pdf-ops.ts new file mode 100644 index 00000000..818e171e --- /dev/null +++ b/packages/doc-engine/src/pdf-ops.ts @@ -0,0 +1,37 @@ +import { runQpdf } from "./qpdf.js"; + +// qpdf page ranges: digits, commas, hyphens, r-prefixed (r1 = last), and z (last page). +const RANGE_RE = /^[0-9rz][0-9rz,-]*$/i; + +export function assertValidRange(range: string): void { + if (!RANGE_RE.test(range) || range.length > 200) { + throw new Error(`Invalid page range: ${range.slice(0, 50)}`); + } +} + +/** Merge inputs (>= 2) into outPath, full pages, input order. */ +export async function qpdfMerge(inputPaths: string[], outPath: string): Promise { + if (inputPaths.length < 2) throw new Error("qpdfMerge needs at least two inputs"); + await runQpdf(["--empty", "--pages", ...inputPaths, "--", outPath], 60_000); +} + +/** Extract a page range (qpdf syntax, e.g. "1-3", "1,3,5", "2-z") into outPath. */ +export async function qpdfSplitRanges( + inputPath: string, + range: string, + outPath: string, +): Promise { + assertValidRange(range); + await runQpdf([inputPath, "--pages", ".", range, "--", outPath], 60_000); +} + +/** Rotate by +angle (90|180|270) applied to a page range (default all: "1-z"). */ +export async function qpdfRotate( + inputPath: string, + angle: 90 | 180 | 270, + range: string, + outPath: string, +): Promise { + assertValidRange(range); + await runQpdf([`--rotate=+${angle}:${range}`, inputPath, outPath], 60_000); +} diff --git a/packages/doc-engine/src/qpdf.ts b/packages/doc-engine/src/qpdf.ts index 813696c2..99f16d29 100644 --- a/packages/doc-engine/src/qpdf.ts +++ b/packages/doc-engine/src/qpdf.ts @@ -1,7 +1,8 @@ import { spawn } from "node:child_process"; import { resolveQpdf } from "./binaries.js"; -function runQpdf(args: string[], timeoutMs = 30_000): Promise { +/** @internal Shared qpdf CLI runner for doc-engine modules; not part of the public package API. */ +export function runQpdf(args: string[], timeoutMs = 30_000): Promise { const bin = resolveQpdf(); if (!bin) throw new Error("qpdf binary not found (set QPDF_PATH or install qpdf)"); return new Promise((resolvePromise, reject) => { diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 2d3202e9..6cc19a2c 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,4 +1,4 @@ -import { IMAGE_INPUTS } from "./modality.js"; +import { AUDIO_INPUTS, IMAGE_INPUTS, VIDEO_INPUTS } from "./modality.js"; import type { CategoryInfo, SocialMediaPreset, Tool } from "./types.js"; export const CATEGORIES: CategoryInfo[] = [ @@ -9,6 +9,10 @@ export const CATEGORIES: CategoryInfo[] = [ { id: "utilities", name: "Utilities", icon: "Wrench", color: "#6366F1" }, { id: "layout", name: "Layout & Composition", icon: "LayoutGrid", color: "#EC4899" }, { id: "format", name: "Format & Conversion", icon: "FileType", color: "#14B8A6" }, + { id: "video", name: "Video", icon: "Video", color: "#EF4444" }, + { id: "audio", name: "Audio", icon: "AudioLines", color: "#10B981" }, + { id: "documents", name: "PDF & Documents", icon: "FileText", color: "#8B5CF6" }, + { id: "data", name: "Data Files", icon: "Table", color: "#F59E0B" }, { id: "ai", name: "AI Tools", icon: "Sparkles", color: "#F59E0B" }, ]; @@ -599,11 +603,202 @@ export const TOOLS: Tool[] = [ id: "pdf-to-image", name: "PDF to Image", description: "Convert PDF pages to images", - category: "format", + category: "documents", icon: "BookImage", route: "/pdf-to-image", - modality: "image", - acceptedInputs: IMAGE_INPUTS, + modality: "document", + acceptedInputs: [".pdf"], + executionHint: "fast", + }, + // Video + { + id: "convert-video", + name: "Convert Video", + description: "Convert videos between MP4, MOV, and WebM", + category: "video", + icon: "Video", + route: "/convert-video", + modality: "video", + acceptedInputs: VIDEO_INPUTS, + executionHint: "long", + }, + { + id: "compress-video", + name: "Compress Video", + description: "Shrink video file size with quality control", + category: "video", + icon: "FileVideo", + route: "/compress-video", + modality: "video", + acceptedInputs: VIDEO_INPUTS, + executionHint: "long", + }, + { + id: "trim-video", + name: "Trim Video", + description: "Cut a clip out of a video", + category: "video", + icon: "Scissors", + route: "/trim-video", + modality: "video", + acceptedInputs: VIDEO_INPUTS, + executionHint: "fast", + }, + { + id: "mute-video", + name: "Mute Video", + description: "Remove the audio track from a video", + category: "video", + icon: "VolumeX", + route: "/mute-video", + modality: "video", + acceptedInputs: VIDEO_INPUTS, + executionHint: "fast", + }, + { + id: "video-to-gif", + name: "Video to GIF", + description: "Turn a video clip into an animated GIF", + category: "video", + icon: "Film", + route: "/video-to-gif", + modality: "video", + acceptedInputs: VIDEO_INPUTS, + executionHint: "long", + }, + // Audio + { + id: "convert-audio", + name: "Convert Audio", + description: "Convert audio between MP3, WAV, OGG, and more", + category: "audio", + icon: "AudioLines", + route: "/convert-audio", + modality: "audio", + acceptedInputs: AUDIO_INPUTS, + executionHint: "fast", + }, + { + id: "trim-audio", + name: "Trim Audio", + description: "Cut a section out of an audio file", + category: "audio", + icon: "Scissors", + route: "/trim-audio", + modality: "audio", + acceptedInputs: AUDIO_INPUTS, + executionHint: "fast", + }, + { + id: "extract-audio", + name: "Extract Audio", + description: "Pull the audio track out of a video", + category: "audio", + icon: "FileAudio", + route: "/extract-audio", + modality: "video", + acceptedInputs: VIDEO_INPUTS, + executionHint: "fast", + }, + // PDF & Documents + { + id: "merge-pdf", + name: "Merge PDFs", + description: "Combine multiple PDFs into one", + category: "documents", + icon: "Combine", + route: "/merge-pdf", + modality: "document", + acceptedInputs: [".pdf"], + executionHint: "fast", + }, + { + id: "split-pdf", + name: "Split PDF", + description: "Extract pages or split into parts", + category: "documents", + icon: "Split", + route: "/split-pdf", + modality: "document", + acceptedInputs: [".pdf"], + executionHint: "fast", + }, + { + id: "compress-pdf", + name: "Compress PDF", + description: "Shrink PDF file size", + category: "documents", + icon: "FileArchive", + route: "/compress-pdf", + modality: "document", + acceptedInputs: [".pdf"], + executionHint: "fast", + }, + { + id: "rotate-pdf", + name: "Rotate PDF", + description: "Rotate pages in a PDF", + category: "documents", + icon: "RotateCw", + route: "/rotate-pdf", + modality: "document", + acceptedInputs: [".pdf"], + executionHint: "fast", + }, + { + id: "word-to-pdf", + name: "Word to PDF", + description: "Convert Word documents to PDF", + category: "documents", + icon: "FileText", + route: "/word-to-pdf", + modality: "document", + acceptedInputs: [".docx", ".doc", ".odt", ".rtf", ".txt"], + executionHint: "long", + }, + // Data Files + { + id: "csv-excel", + name: "CSV to Excel", + description: "Convert between CSV and Excel (XLSX), both directions", + category: "data", + icon: "Table", + route: "/csv-excel", + modality: "file", + acceptedInputs: [".csv", ".xlsx"], + executionHint: "fast", + }, + { + id: "csv-json", + name: "CSV to JSON", + description: "Convert between CSV and JSON, both directions", + category: "data", + icon: "Braces", + route: "/csv-json", + modality: "file", + acceptedInputs: [".csv", ".json"], + executionHint: "fast", + }, + { + id: "json-xml", + name: "JSON to XML", + description: "Convert between JSON and XML, both directions", + category: "data", + icon: "Code", + route: "/json-xml", + modality: "file", + acceptedInputs: [".json", ".xml"], + executionHint: "fast", + }, + { + id: "split-csv", + name: "Split CSV", + description: "Split a CSV into smaller files by row count", + category: "data", + icon: "Split", + route: "/split-csv", + modality: "file", + acceptedInputs: [".csv"], executionHint: "fast", }, ]; diff --git a/packages/shared/src/i18n/ar.ts b/packages/shared/src/i18n/ar.ts index 16b42d63..5be1d1ab 100644 --- a/packages/shared/src/i18n/ar.ts +++ b/packages/shared/src/i18n/ar.ts @@ -47,6 +47,10 @@ export const ar: TranslationKeys = { utilities: "الأدوات المساعدة", layout: "التخطيط والتكوين", format: "التنسيق والتحويل", + video: "Video", + audio: "Audio", + documents: "PDF & Documents", + data: "Data Files", ai: "أدوات AI", }, modalities: { @@ -272,6 +276,74 @@ export const ar: TranslationKeys = { name: "صورة إلى Base64", description: "تحويل الصور إلى سلاسل Base64 لتضمينها في HTML و CSS والمزيد", }, + "convert-video": { + name: "Convert Video", + description: "Convert videos between MP4, MOV, and WebM", + }, + "compress-video": { + name: "Compress Video", + description: "Shrink video file size with quality control", + }, + "trim-video": { + name: "Trim Video", + description: "Cut a clip out of a video", + }, + "mute-video": { + name: "Mute Video", + description: "Remove the audio track from a video", + }, + "video-to-gif": { + name: "Video to GIF", + description: "Turn a video clip into an animated GIF", + }, + "convert-audio": { + name: "Convert Audio", + description: "Convert audio between MP3, WAV, OGG, and more", + }, + "trim-audio": { + name: "Trim Audio", + description: "Cut a section out of an audio file", + }, + "extract-audio": { + name: "Extract Audio", + description: "Pull the audio track out of a video", + }, + "merge-pdf": { + name: "Merge PDFs", + description: "Combine multiple PDFs into one", + }, + "split-pdf": { + name: "Split PDF", + description: "Extract pages or split into parts", + }, + "compress-pdf": { + name: "Compress PDF", + description: "Shrink PDF file size", + }, + "rotate-pdf": { + name: "Rotate PDF", + description: "Rotate pages in a PDF", + }, + "word-to-pdf": { + name: "Word to PDF", + description: "Convert Word documents to PDF", + }, + "csv-excel": { + name: "CSV to Excel", + description: "Convert between CSV and Excel (XLSX), both directions", + }, + "csv-json": { + name: "CSV to JSON", + description: "Convert between CSV and JSON, both directions", + }, + "json-xml": { + name: "JSON to XML", + description: "Convert between JSON and XML, both directions", + }, + "split-csv": { + name: "Split CSV", + description: "Split a CSV into smaller files by row count", + }, pipeline: { name: "منشئ Pipeline", description: "ربط عدة أدوات في سير عمل واحد", @@ -1231,6 +1303,133 @@ export const ar: TranslationKeys = { balanced: "متوازن", best: "الأفضل", }, + "convert-video": { + format: "Output format", + quality: "Quality", + high: "High", + balanced: "Balanced", + small: "Smallest", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "compress-video": { + quality: "Compression", + light: "Light", + balanced: "Balanced", + strong: "Strong", + resolution: "Resolution", + original: "Original", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "trim-video": { + start: "Start (seconds)", + end: "End (seconds)", + precise: "Frame-accurate (re-encodes)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "mute-video": { + submit: "Remove audio", + submitBatch: "Remove audio ({count} files)", + progressLabel: "Muting", + }, + "video-to-gif": { + fps: "Frames per second", + width: "Width (px)", + start: "Start (seconds)", + duration: "Duration (seconds)", + submit: "Create GIF", + submitBatch: "Create GIFs ({count} files)", + progressLabel: "Creating GIF", + }, + "convert-audio": { + format: "Output format", + bitrate: "Bitrate (kbps)", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "trim-audio": { + start: "Start (seconds)", + end: "End (seconds)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "extract-audio": { + format: "Output format", + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, + "merge-pdf": { + addFiles: "Add PDFs to merge", + needTwo: "At least two PDFs are required", + orderHint: "{count} PDFs will merge in this order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "split-pdf": { + mode: "Split mode", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 7-z", + everyN: "Pages per part", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, + "compress-pdf": { + preset: "Quality preset", + screen: "Screen (smallest)", + ebook: "Ebook (balanced)", + printer: "Printer (best quality)", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "rotate-pdf": { + angle: "Rotation angle", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 1-z (all pages)", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "word-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-excel": { + sheet: "Worksheet number", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-json": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "json-xml": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "split-csv": { + rowsPerFile: "Rows per file", + keepHeader: "Keep header in each file", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, }, urlImport: { title: "استيراد من روابط", @@ -1775,7 +1974,7 @@ export const ar: TranslationKeys = { heading: "حول", appName: "SnapOtter", appDescription: - "مجموعة أدوات معالجة صور ذاتية الاستضافة تركز على الخصوصية مع 53 أداة. تغيير الحجم والضغط والتحويل والعلامات المائية وأتمتة سير عمل الصور دون إرسال البيانات إلى السحابة.", + "مجموعة أدوات معالجة صور ذاتية الاستضافة تركز على الخصوصية مع 70+ أداة. تغيير الحجم والضغط والتحويل والعلامات المائية وأتمتة سير عمل الصور دون إرسال البيانات إلى السحابة.", versionLabel: "الإصدار:", linksHeading: "روابط", githubLink: "مستودع GitHub", @@ -1820,7 +2019,7 @@ export const ar: TranslationKeys = { "بلا حدود. بلا قيود خفية.", "يعمل بدون إنترنت.", "معالجة جماعية بلا حدود.", - "53 أداة للصور.", + "70+ أداة للصور.", "15 نموذج AI. على جهازك.", "سريع كالبرق. مبني على Sharp.", "يعمل في بيئات معزولة.", diff --git a/packages/shared/src/i18n/de.ts b/packages/shared/src/i18n/de.ts index baa9ece2..b0af48a6 100644 --- a/packages/shared/src/i18n/de.ts +++ b/packages/shared/src/i18n/de.ts @@ -47,6 +47,10 @@ export const de: TranslationKeys = { utilities: "Hilfswerkzeuge", layout: "Layout & Komposition", format: "Format & Konvertierung", + video: "Video", + audio: "Audio", + documents: "PDF & Documents", + data: "Data Files", ai: "AI-Werkzeuge", }, modalities: { @@ -277,6 +281,74 @@ export const de: TranslationKeys = { description: "Bilder in Base64-Zeichenketten zum Einbetten in HTML, CSS und mehr konvertieren", }, + "convert-video": { + name: "Convert Video", + description: "Convert videos between MP4, MOV, and WebM", + }, + "compress-video": { + name: "Compress Video", + description: "Shrink video file size with quality control", + }, + "trim-video": { + name: "Trim Video", + description: "Cut a clip out of a video", + }, + "mute-video": { + name: "Mute Video", + description: "Remove the audio track from a video", + }, + "video-to-gif": { + name: "Video to GIF", + description: "Turn a video clip into an animated GIF", + }, + "convert-audio": { + name: "Convert Audio", + description: "Convert audio between MP3, WAV, OGG, and more", + }, + "trim-audio": { + name: "Trim Audio", + description: "Cut a section out of an audio file", + }, + "extract-audio": { + name: "Extract Audio", + description: "Pull the audio track out of a video", + }, + "merge-pdf": { + name: "Merge PDFs", + description: "Combine multiple PDFs into one", + }, + "split-pdf": { + name: "Split PDF", + description: "Extract pages or split into parts", + }, + "compress-pdf": { + name: "Compress PDF", + description: "Shrink PDF file size", + }, + "rotate-pdf": { + name: "Rotate PDF", + description: "Rotate pages in a PDF", + }, + "word-to-pdf": { + name: "Word to PDF", + description: "Convert Word documents to PDF", + }, + "csv-excel": { + name: "CSV to Excel", + description: "Convert between CSV and Excel (XLSX), both directions", + }, + "csv-json": { + name: "CSV to JSON", + description: "Convert between CSV and JSON, both directions", + }, + "json-xml": { + name: "JSON to XML", + description: "Convert between JSON and XML, both directions", + }, + "split-csv": { + name: "Split CSV", + description: "Split a CSV into smaller files by row count", + }, pipeline: { name: "Pipeline-Builder", description: "Mehrere Werkzeuge zu einem Workflow verketten", @@ -1244,6 +1316,133 @@ export const de: TranslationKeys = { balanced: "Ausgewogen", best: "Beste", }, + "convert-video": { + format: "Output format", + quality: "Quality", + high: "High", + balanced: "Balanced", + small: "Smallest", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "compress-video": { + quality: "Compression", + light: "Light", + balanced: "Balanced", + strong: "Strong", + resolution: "Resolution", + original: "Original", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "trim-video": { + start: "Start (seconds)", + end: "End (seconds)", + precise: "Frame-accurate (re-encodes)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "mute-video": { + submit: "Remove audio", + submitBatch: "Remove audio ({count} files)", + progressLabel: "Muting", + }, + "video-to-gif": { + fps: "Frames per second", + width: "Width (px)", + start: "Start (seconds)", + duration: "Duration (seconds)", + submit: "Create GIF", + submitBatch: "Create GIFs ({count} files)", + progressLabel: "Creating GIF", + }, + "convert-audio": { + format: "Output format", + bitrate: "Bitrate (kbps)", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "trim-audio": { + start: "Start (seconds)", + end: "End (seconds)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "extract-audio": { + format: "Output format", + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, + "merge-pdf": { + addFiles: "Add PDFs to merge", + needTwo: "At least two PDFs are required", + orderHint: "{count} PDFs will merge in this order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "split-pdf": { + mode: "Split mode", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 7-z", + everyN: "Pages per part", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, + "compress-pdf": { + preset: "Quality preset", + screen: "Screen (smallest)", + ebook: "Ebook (balanced)", + printer: "Printer (best quality)", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "rotate-pdf": { + angle: "Rotation angle", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 1-z (all pages)", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "word-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-excel": { + sheet: "Worksheet number", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-json": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "json-xml": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "split-csv": { + rowsPerFile: "Rows per file", + keepHeader: "Keep header in each file", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, }, urlImport: { title: "Von URLs importieren", @@ -1800,7 +1999,7 @@ export const de: TranslationKeys = { heading: "Info", appName: "SnapOtter", appDescription: - "Eine selbst gehostete, datenschutzorientierte Bildverarbeitungssuite mit 53 Werkzeugen. Skalieren, komprimieren, konvertieren, mit Wasserzeichen versehen und Bildablaeufe automatisieren, ohne Daten in die Cloud zu senden.", + "Eine selbst gehostete, datenschutzorientierte Bildverarbeitungssuite mit 70+ Werkzeugen. Skalieren, komprimieren, konvertieren, mit Wasserzeichen versehen und Bildablaeufe automatisieren, ohne Daten in die Cloud zu senden.", versionLabel: "Version:", linksHeading: "Links", githubLink: "GitHub-Repository", @@ -1848,7 +2047,7 @@ export const de: TranslationKeys = { "Keine Limits. Keine versteckten Grenzen.", "Funktioniert komplett offline.", "Unbegrenzte Stapelverarbeitung.", - "53 Bildwerkzeuge.", + "70+ Bildwerkzeuge.", "16 AI-Modelle. Ihre Hardware.", "Blitzschnell. Basiert auf Sharp.", "Geeignet fuer abgeschottete Netzwerke.", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index c63ca5de..50fe5e4a 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -45,6 +45,10 @@ export const en = { utilities: "Utilities", layout: "Layout & Composition", format: "Format & Conversion", + video: "Video", + audio: "Audio", + documents: "PDF & Documents", + data: "Data Files", ai: "AI Tools", }, modalities: { @@ -232,6 +236,74 @@ export const en = { name: "Image to Base64", description: "Convert images to base64 strings for embedding in HTML, CSS, and more", }, + "convert-video": { + name: "Convert Video", + description: "Convert videos between MP4, MOV, and WebM", + }, + "compress-video": { + name: "Compress Video", + description: "Shrink video file size with quality control", + }, + "trim-video": { + name: "Trim Video", + description: "Cut a clip out of a video", + }, + "mute-video": { + name: "Mute Video", + description: "Remove the audio track from a video", + }, + "video-to-gif": { + name: "Video to GIF", + description: "Turn a video clip into an animated GIF", + }, + "convert-audio": { + name: "Convert Audio", + description: "Convert audio between MP3, WAV, OGG, and more", + }, + "trim-audio": { + name: "Trim Audio", + description: "Cut a section out of an audio file", + }, + "extract-audio": { + name: "Extract Audio", + description: "Pull the audio track out of a video", + }, + "merge-pdf": { + name: "Merge PDFs", + description: "Combine multiple PDFs into one", + }, + "split-pdf": { + name: "Split PDF", + description: "Extract pages or split into parts", + }, + "compress-pdf": { + name: "Compress PDF", + description: "Shrink PDF file size", + }, + "rotate-pdf": { + name: "Rotate PDF", + description: "Rotate pages in a PDF", + }, + "word-to-pdf": { + name: "Word to PDF", + description: "Convert Word documents to PDF", + }, + "csv-excel": { + name: "CSV to Excel", + description: "Convert between CSV and Excel (XLSX), both directions", + }, + "csv-json": { + name: "CSV to JSON", + description: "Convert between CSV and JSON, both directions", + }, + "json-xml": { + name: "JSON to XML", + description: "Convert between JSON and XML, both directions", + }, + "split-csv": { + name: "Split CSV", + description: "Split a CSV into smaller files by row count", + }, pipeline: { name: "Pipeline Builder", description: "Chain multiple tools into a workflow" }, processing: { canceled: "Processing canceled" }, mediaPlayer: { @@ -1189,6 +1261,133 @@ export const en = { balanced: "Balanced", best: "Best", }, + "convert-video": { + format: "Output format", + quality: "Quality", + high: "High", + balanced: "Balanced", + small: "Smallest", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "compress-video": { + quality: "Compression", + light: "Light", + balanced: "Balanced", + strong: "Strong", + resolution: "Resolution", + original: "Original", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "trim-video": { + start: "Start (seconds)", + end: "End (seconds)", + precise: "Frame-accurate (re-encodes)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "mute-video": { + submit: "Remove audio", + submitBatch: "Remove audio ({count} files)", + progressLabel: "Muting", + }, + "video-to-gif": { + fps: "Frames per second", + width: "Width (px)", + start: "Start (seconds)", + duration: "Duration (seconds)", + submit: "Create GIF", + submitBatch: "Create GIFs ({count} files)", + progressLabel: "Creating GIF", + }, + "convert-audio": { + format: "Output format", + bitrate: "Bitrate (kbps)", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "trim-audio": { + start: "Start (seconds)", + end: "End (seconds)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "extract-audio": { + format: "Output format", + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, + "merge-pdf": { + addFiles: "Add PDFs to merge", + needTwo: "At least two PDFs are required", + orderHint: "{count} PDFs will merge in this order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "split-pdf": { + mode: "Split mode", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 7-z", + everyN: "Pages per part", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, + "compress-pdf": { + preset: "Quality preset", + screen: "Screen (smallest)", + ebook: "Ebook (balanced)", + printer: "Printer (best quality)", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "rotate-pdf": { + angle: "Rotation angle", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 1-z (all pages)", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "word-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-excel": { + sheet: "Worksheet number", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-json": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "json-xml": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "split-csv": { + rowsPerFile: "Rows per file", + keepHeader: "Keep header in each file", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, }, urlImport: { title: "Import from URLs", @@ -1735,7 +1934,7 @@ export const en = { heading: "About", appName: "SnapOtter", appDescription: - "A self-hosted, privacy-first image processing suite with 53 tools. Resize, compress, convert, watermark, and automate your image workflows without sending data to the cloud.", + "A self-hosted, privacy-first image processing suite with 70+ tools. Resize, compress, convert, watermark, and automate your image workflows without sending data to the cloud.", versionLabel: "Version:", linksHeading: "Links", githubLink: "GitHub Repository", @@ -1781,7 +1980,7 @@ export const en = { "No limits. No hidden caps.", "Works fully offline.", "Unlimited batch processing.", - "53 image tools.", + "70+ tools.", "16 AI models. Your hardware.", "Lightning fast. Built on Sharp.", "Air-gapped ready.", diff --git a/packages/shared/src/i18n/es.ts b/packages/shared/src/i18n/es.ts index 08fb14fb..63e6f3ff 100644 --- a/packages/shared/src/i18n/es.ts +++ b/packages/shared/src/i18n/es.ts @@ -47,6 +47,10 @@ export const es: TranslationKeys = { utilities: "Utilidades", layout: "Diseno y composicion", format: "Formato y conversion", + video: "Video", + audio: "Audio", + documents: "PDF & Documents", + data: "Data Files", ai: "Herramientas de AI", }, modalities: { @@ -262,6 +266,74 @@ export const es: TranslationKeys = { name: "Imagen a Base64", description: "Convierte imagenes a cadenas Base64 para incrustar en HTML, CSS y mas", }, + "convert-video": { + name: "Convert Video", + description: "Convert videos between MP4, MOV, and WebM", + }, + "compress-video": { + name: "Compress Video", + description: "Shrink video file size with quality control", + }, + "trim-video": { + name: "Trim Video", + description: "Cut a clip out of a video", + }, + "mute-video": { + name: "Mute Video", + description: "Remove the audio track from a video", + }, + "video-to-gif": { + name: "Video to GIF", + description: "Turn a video clip into an animated GIF", + }, + "convert-audio": { + name: "Convert Audio", + description: "Convert audio between MP3, WAV, OGG, and more", + }, + "trim-audio": { + name: "Trim Audio", + description: "Cut a section out of an audio file", + }, + "extract-audio": { + name: "Extract Audio", + description: "Pull the audio track out of a video", + }, + "merge-pdf": { + name: "Merge PDFs", + description: "Combine multiple PDFs into one", + }, + "split-pdf": { + name: "Split PDF", + description: "Extract pages or split into parts", + }, + "compress-pdf": { + name: "Compress PDF", + description: "Shrink PDF file size", + }, + "rotate-pdf": { + name: "Rotate PDF", + description: "Rotate pages in a PDF", + }, + "word-to-pdf": { + name: "Word to PDF", + description: "Convert Word documents to PDF", + }, + "csv-excel": { + name: "CSV to Excel", + description: "Convert between CSV and Excel (XLSX), both directions", + }, + "csv-json": { + name: "CSV to JSON", + description: "Convert between CSV and JSON, both directions", + }, + "json-xml": { + name: "JSON to XML", + description: "Convert between JSON and XML, both directions", + }, + "split-csv": { + name: "Split CSV", + description: "Split a CSV into smaller files by row count", + }, pipeline: { name: "Constructor de Pipeline", description: "Encadena multiples herramientas en un flujo de trabajo", @@ -1226,6 +1298,133 @@ export const es: TranslationKeys = { balanced: "Equilibrado", best: "Mejor", }, + "convert-video": { + format: "Output format", + quality: "Quality", + high: "High", + balanced: "Balanced", + small: "Smallest", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "compress-video": { + quality: "Compression", + light: "Light", + balanced: "Balanced", + strong: "Strong", + resolution: "Resolution", + original: "Original", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "trim-video": { + start: "Start (seconds)", + end: "End (seconds)", + precise: "Frame-accurate (re-encodes)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "mute-video": { + submit: "Remove audio", + submitBatch: "Remove audio ({count} files)", + progressLabel: "Muting", + }, + "video-to-gif": { + fps: "Frames per second", + width: "Width (px)", + start: "Start (seconds)", + duration: "Duration (seconds)", + submit: "Create GIF", + submitBatch: "Create GIFs ({count} files)", + progressLabel: "Creating GIF", + }, + "convert-audio": { + format: "Output format", + bitrate: "Bitrate (kbps)", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "trim-audio": { + start: "Start (seconds)", + end: "End (seconds)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "extract-audio": { + format: "Output format", + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, + "merge-pdf": { + addFiles: "Add PDFs to merge", + needTwo: "At least two PDFs are required", + orderHint: "{count} PDFs will merge in this order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "split-pdf": { + mode: "Split mode", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 7-z", + everyN: "Pages per part", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, + "compress-pdf": { + preset: "Quality preset", + screen: "Screen (smallest)", + ebook: "Ebook (balanced)", + printer: "Printer (best quality)", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "rotate-pdf": { + angle: "Rotation angle", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 1-z (all pages)", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "word-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-excel": { + sheet: "Worksheet number", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-json": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "json-xml": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "split-csv": { + rowsPerFile: "Rows per file", + keepHeader: "Keep header in each file", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, }, urlImport: { title: "Importar desde URLs", @@ -1779,7 +1978,7 @@ export const es: TranslationKeys = { heading: "Acerca de", appName: "SnapOtter", appDescription: - "Una suite de procesamiento de imagenes autoalojada y con privacidad como prioridad, con 53 herramientas. Redimensiona, comprime, convierte, agrega marcas de agua y automatiza tus flujos de trabajo de imagenes sin enviar datos a la nube.", + "Una suite de procesamiento de imagenes autoalojada y con privacidad como prioridad, con 70+ herramientas. Redimensiona, comprime, convierte, agrega marcas de agua y automatiza tus flujos de trabajo de imagenes sin enviar datos a la nube.", versionLabel: "Version:", linksHeading: "Enlaces", githubLink: "Repositorio en GitHub", @@ -1826,7 +2025,7 @@ export const es: TranslationKeys = { "Sin limites. Sin topes ocultos.", "Funciona totalmente sin conexion.", "Procesamiento por lotes ilimitado.", - "53 herramientas de imagen.", + "70+ herramientas de imagen.", "15 modelos AI. Tu hardware.", "Ultra rapido. Construido sobre Sharp.", "Listo para redes aisladas.", diff --git a/packages/shared/src/i18n/fr.ts b/packages/shared/src/i18n/fr.ts index 2466228f..c977ea69 100644 --- a/packages/shared/src/i18n/fr.ts +++ b/packages/shared/src/i18n/fr.ts @@ -47,6 +47,10 @@ export const fr: TranslationKeys = { utilities: "Utilitaires", layout: "Mise en page et composition", format: "Format et conversion", + video: "Video", + audio: "Audio", + documents: "PDF & Documents", + data: "Data Files", ai: "Outils AI", }, modalities: { @@ -278,6 +282,74 @@ export const fr: TranslationKeys = { description: "Convertissez des images en chaines Base64 pour les integrer dans HTML, CSS et plus", }, + "convert-video": { + name: "Convert Video", + description: "Convert videos between MP4, MOV, and WebM", + }, + "compress-video": { + name: "Compress Video", + description: "Shrink video file size with quality control", + }, + "trim-video": { + name: "Trim Video", + description: "Cut a clip out of a video", + }, + "mute-video": { + name: "Mute Video", + description: "Remove the audio track from a video", + }, + "video-to-gif": { + name: "Video to GIF", + description: "Turn a video clip into an animated GIF", + }, + "convert-audio": { + name: "Convert Audio", + description: "Convert audio between MP3, WAV, OGG, and more", + }, + "trim-audio": { + name: "Trim Audio", + description: "Cut a section out of an audio file", + }, + "extract-audio": { + name: "Extract Audio", + description: "Pull the audio track out of a video", + }, + "merge-pdf": { + name: "Merge PDFs", + description: "Combine multiple PDFs into one", + }, + "split-pdf": { + name: "Split PDF", + description: "Extract pages or split into parts", + }, + "compress-pdf": { + name: "Compress PDF", + description: "Shrink PDF file size", + }, + "rotate-pdf": { + name: "Rotate PDF", + description: "Rotate pages in a PDF", + }, + "word-to-pdf": { + name: "Word to PDF", + description: "Convert Word documents to PDF", + }, + "csv-excel": { + name: "CSV to Excel", + description: "Convert between CSV and Excel (XLSX), both directions", + }, + "csv-json": { + name: "CSV to JSON", + description: "Convert between CSV and JSON, both directions", + }, + "json-xml": { + name: "JSON to XML", + description: "Convert between JSON and XML, both directions", + }, + "split-csv": { + name: "Split CSV", + description: "Split a CSV into smaller files by row count", + }, pipeline: { name: "Constructeur de Pipeline", description: "Enchainez plusieurs outils dans un flux de travail", @@ -1245,6 +1317,133 @@ export const fr: TranslationKeys = { balanced: "Equilibre", best: "Optimal", }, + "convert-video": { + format: "Output format", + quality: "Quality", + high: "High", + balanced: "Balanced", + small: "Smallest", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "compress-video": { + quality: "Compression", + light: "Light", + balanced: "Balanced", + strong: "Strong", + resolution: "Resolution", + original: "Original", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "trim-video": { + start: "Start (seconds)", + end: "End (seconds)", + precise: "Frame-accurate (re-encodes)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "mute-video": { + submit: "Remove audio", + submitBatch: "Remove audio ({count} files)", + progressLabel: "Muting", + }, + "video-to-gif": { + fps: "Frames per second", + width: "Width (px)", + start: "Start (seconds)", + duration: "Duration (seconds)", + submit: "Create GIF", + submitBatch: "Create GIFs ({count} files)", + progressLabel: "Creating GIF", + }, + "convert-audio": { + format: "Output format", + bitrate: "Bitrate (kbps)", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "trim-audio": { + start: "Start (seconds)", + end: "End (seconds)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "extract-audio": { + format: "Output format", + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, + "merge-pdf": { + addFiles: "Add PDFs to merge", + needTwo: "At least two PDFs are required", + orderHint: "{count} PDFs will merge in this order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "split-pdf": { + mode: "Split mode", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 7-z", + everyN: "Pages per part", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, + "compress-pdf": { + preset: "Quality preset", + screen: "Screen (smallest)", + ebook: "Ebook (balanced)", + printer: "Printer (best quality)", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "rotate-pdf": { + angle: "Rotation angle", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 1-z (all pages)", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "word-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-excel": { + sheet: "Worksheet number", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-json": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "json-xml": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "split-csv": { + rowsPerFile: "Rows per file", + keepHeader: "Keep header in each file", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, }, urlImport: { title: "Importer depuis des URLs", @@ -1798,7 +1997,7 @@ export const fr: TranslationKeys = { heading: "A propos", appName: "SnapOtter", appDescription: - "Une suite de traitement d'images auto-hebergee, axee sur la confidentialite, avec 53 outils. Redimensionnez, compressez, convertissez, ajoutez des filigranes et automatisez vos flux de travail sans envoyer de donnees vers le cloud.", + "Une suite de traitement d'images auto-hebergee, axee sur la confidentialite, avec 70+ outils. Redimensionnez, compressez, convertissez, ajoutez des filigranes et automatisez vos flux de travail sans envoyer de donnees vers le cloud.", versionLabel: "Version :", linksHeading: "Liens", githubLink: "Depot GitHub", @@ -1846,7 +2045,7 @@ export const fr: TranslationKeys = { "Sans limites. Sans plafonds caches.", "Fonctionne entierement hors ligne.", "Traitement par lot illimite.", - "53 outils image.", + "70+ outils image.", "15 modeles AI. Votre materiel.", "Ultra rapide. Propulse par Sharp.", "Pret pour les reseaux isoles.", diff --git a/packages/shared/src/i18n/hi.ts b/packages/shared/src/i18n/hi.ts index 86365b4f..47593ed7 100644 --- a/packages/shared/src/i18n/hi.ts +++ b/packages/shared/src/i18n/hi.ts @@ -47,6 +47,10 @@ export const hi: TranslationKeys = { utilities: "सहायक टूल्स", layout: "लेआउट और कंपोज़िशन", format: "फॉर्मेट और कन्वर्शन", + video: "Video", + audio: "Audio", + documents: "PDF & Documents", + data: "Data Files", ai: "AI टूल्स", }, modalities: { @@ -269,6 +273,74 @@ export const hi: TranslationKeys = { name: "इमेज से Base64", description: "HTML, CSS और अन्य में एम्बेड करने के लिए इमेज को Base64 स्ट्रिंग में कन्वर्ट करें", }, + "convert-video": { + name: "Convert Video", + description: "Convert videos between MP4, MOV, and WebM", + }, + "compress-video": { + name: "Compress Video", + description: "Shrink video file size with quality control", + }, + "trim-video": { + name: "Trim Video", + description: "Cut a clip out of a video", + }, + "mute-video": { + name: "Mute Video", + description: "Remove the audio track from a video", + }, + "video-to-gif": { + name: "Video to GIF", + description: "Turn a video clip into an animated GIF", + }, + "convert-audio": { + name: "Convert Audio", + description: "Convert audio between MP3, WAV, OGG, and more", + }, + "trim-audio": { + name: "Trim Audio", + description: "Cut a section out of an audio file", + }, + "extract-audio": { + name: "Extract Audio", + description: "Pull the audio track out of a video", + }, + "merge-pdf": { + name: "Merge PDFs", + description: "Combine multiple PDFs into one", + }, + "split-pdf": { + name: "Split PDF", + description: "Extract pages or split into parts", + }, + "compress-pdf": { + name: "Compress PDF", + description: "Shrink PDF file size", + }, + "rotate-pdf": { + name: "Rotate PDF", + description: "Rotate pages in a PDF", + }, + "word-to-pdf": { + name: "Word to PDF", + description: "Convert Word documents to PDF", + }, + "csv-excel": { + name: "CSV to Excel", + description: "Convert between CSV and Excel (XLSX), both directions", + }, + "csv-json": { + name: "CSV to JSON", + description: "Convert between CSV and JSON, both directions", + }, + "json-xml": { + name: "JSON to XML", + description: "Convert between JSON and XML, both directions", + }, + "split-csv": { + name: "Split CSV", + description: "Split a CSV into smaller files by row count", + }, pipeline: { name: "Pipeline बिल्डर", description: "कई टूल्स को एक वर्कफ्लो में चेन करें", @@ -1227,6 +1299,133 @@ export const hi: TranslationKeys = { balanced: "संतुलित", best: "सर्वोत्तम", }, + "convert-video": { + format: "Output format", + quality: "Quality", + high: "High", + balanced: "Balanced", + small: "Smallest", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "compress-video": { + quality: "Compression", + light: "Light", + balanced: "Balanced", + strong: "Strong", + resolution: "Resolution", + original: "Original", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "trim-video": { + start: "Start (seconds)", + end: "End (seconds)", + precise: "Frame-accurate (re-encodes)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "mute-video": { + submit: "Remove audio", + submitBatch: "Remove audio ({count} files)", + progressLabel: "Muting", + }, + "video-to-gif": { + fps: "Frames per second", + width: "Width (px)", + start: "Start (seconds)", + duration: "Duration (seconds)", + submit: "Create GIF", + submitBatch: "Create GIFs ({count} files)", + progressLabel: "Creating GIF", + }, + "convert-audio": { + format: "Output format", + bitrate: "Bitrate (kbps)", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "trim-audio": { + start: "Start (seconds)", + end: "End (seconds)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "extract-audio": { + format: "Output format", + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, + "merge-pdf": { + addFiles: "Add PDFs to merge", + needTwo: "At least two PDFs are required", + orderHint: "{count} PDFs will merge in this order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "split-pdf": { + mode: "Split mode", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 7-z", + everyN: "Pages per part", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, + "compress-pdf": { + preset: "Quality preset", + screen: "Screen (smallest)", + ebook: "Ebook (balanced)", + printer: "Printer (best quality)", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "rotate-pdf": { + angle: "Rotation angle", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 1-z (all pages)", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "word-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-excel": { + sheet: "Worksheet number", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-json": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "json-xml": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "split-csv": { + rowsPerFile: "Rows per file", + keepHeader: "Keep header in each file", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, }, urlImport: { title: "URL से इम्पोर्ट करें", @@ -1771,7 +1970,7 @@ export const hi: TranslationKeys = { heading: "जानकारी", appName: "SnapOtter", appDescription: - "53 टूल्स के साथ गोपनीयता-पहले, सेल्फ-होस्टेड इमेज प्रोसेसिंग सूट। क्लाउड को डेटा भेजे बिना रीसाइज़, कंप्रेस, कन्वर्ट, वॉटरमार्क और अपने इमेज वर्कफ्लो ऑटोमेट करें।", + "70+ टूल्स के साथ गोपनीयता-पहले, सेल्फ-होस्टेड इमेज प्रोसेसिंग सूट। क्लाउड को डेटा भेजे बिना रीसाइज़, कंप्रेस, कन्वर्ट, वॉटरमार्क और अपने इमेज वर्कफ्लो ऑटोमेट करें।", versionLabel: "वर्शन:", linksHeading: "लिंक्स", githubLink: "GitHub रिपॉज़िटरी", @@ -1817,7 +2016,7 @@ export const hi: TranslationKeys = { "कोई सीमा नहीं। कोई छुपी हुई कैप नहीं।", "पूरी तरह ऑफलाइन काम करता है।", "असीमित बैच प्रोसेसिंग।", - "53 इमेज टूल्स।", + "70+ इमेज टूल्स।", "16 AI मॉडल। आपका हार्डवेयर।", "बिजली की तरह तेज़। Sharp पर बनाया गया।", "एयर-गैप्ड नेटवर्क के लिए तैयार।", diff --git a/packages/shared/src/i18n/id.ts b/packages/shared/src/i18n/id.ts index 48ddc98d..c738f33a 100644 --- a/packages/shared/src/i18n/id.ts +++ b/packages/shared/src/i18n/id.ts @@ -47,6 +47,10 @@ export const id: TranslationKeys = { utilities: "Utilitas", layout: "Tata Letak & Komposisi", format: "Format & Konversi", + video: "Video", + audio: "Audio", + documents: "PDF & Documents", + data: "Data Files", ai: "Alat AI", }, modalities: { @@ -277,6 +281,74 @@ export const id: TranslationKeys = { name: "Gambar ke Base64", description: "Konversi gambar ke string Base64 untuk disematkan di HTML, CSS, dan lainnya", }, + "convert-video": { + name: "Convert Video", + description: "Convert videos between MP4, MOV, and WebM", + }, + "compress-video": { + name: "Compress Video", + description: "Shrink video file size with quality control", + }, + "trim-video": { + name: "Trim Video", + description: "Cut a clip out of a video", + }, + "mute-video": { + name: "Mute Video", + description: "Remove the audio track from a video", + }, + "video-to-gif": { + name: "Video to GIF", + description: "Turn a video clip into an animated GIF", + }, + "convert-audio": { + name: "Convert Audio", + description: "Convert audio between MP3, WAV, OGG, and more", + }, + "trim-audio": { + name: "Trim Audio", + description: "Cut a section out of an audio file", + }, + "extract-audio": { + name: "Extract Audio", + description: "Pull the audio track out of a video", + }, + "merge-pdf": { + name: "Merge PDFs", + description: "Combine multiple PDFs into one", + }, + "split-pdf": { + name: "Split PDF", + description: "Extract pages or split into parts", + }, + "compress-pdf": { + name: "Compress PDF", + description: "Shrink PDF file size", + }, + "rotate-pdf": { + name: "Rotate PDF", + description: "Rotate pages in a PDF", + }, + "word-to-pdf": { + name: "Word to PDF", + description: "Convert Word documents to PDF", + }, + "csv-excel": { + name: "CSV to Excel", + description: "Convert between CSV and Excel (XLSX), both directions", + }, + "csv-json": { + name: "CSV to JSON", + description: "Convert between CSV and JSON, both directions", + }, + "json-xml": { + name: "JSON to XML", + description: "Convert between JSON and XML, both directions", + }, + "split-csv": { + name: "Split CSV", + description: "Split a CSV into smaller files by row count", + }, pipeline: { name: "Pipeline Builder", description: "Rangkai beberapa alat menjadi alur kerja", @@ -1239,6 +1311,133 @@ export const id: TranslationKeys = { balanced: "Seimbang", best: "Terbaik", }, + "convert-video": { + format: "Output format", + quality: "Quality", + high: "High", + balanced: "Balanced", + small: "Smallest", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "compress-video": { + quality: "Compression", + light: "Light", + balanced: "Balanced", + strong: "Strong", + resolution: "Resolution", + original: "Original", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "trim-video": { + start: "Start (seconds)", + end: "End (seconds)", + precise: "Frame-accurate (re-encodes)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "mute-video": { + submit: "Remove audio", + submitBatch: "Remove audio ({count} files)", + progressLabel: "Muting", + }, + "video-to-gif": { + fps: "Frames per second", + width: "Width (px)", + start: "Start (seconds)", + duration: "Duration (seconds)", + submit: "Create GIF", + submitBatch: "Create GIFs ({count} files)", + progressLabel: "Creating GIF", + }, + "convert-audio": { + format: "Output format", + bitrate: "Bitrate (kbps)", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "trim-audio": { + start: "Start (seconds)", + end: "End (seconds)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "extract-audio": { + format: "Output format", + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, + "merge-pdf": { + addFiles: "Add PDFs to merge", + needTwo: "At least two PDFs are required", + orderHint: "{count} PDFs will merge in this order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "split-pdf": { + mode: "Split mode", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 7-z", + everyN: "Pages per part", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, + "compress-pdf": { + preset: "Quality preset", + screen: "Screen (smallest)", + ebook: "Ebook (balanced)", + printer: "Printer (best quality)", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "rotate-pdf": { + angle: "Rotation angle", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 1-z (all pages)", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "word-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-excel": { + sheet: "Worksheet number", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-json": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "json-xml": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "split-csv": { + rowsPerFile: "Rows per file", + keepHeader: "Keep header in each file", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, }, urlImport: { title: "Impor dari URL", @@ -1787,7 +1986,7 @@ export const id: TranslationKeys = { heading: "Tentang", appName: "SnapOtter", appDescription: - "Suite pemrosesan gambar self-hosted yang mengutamakan privasi dengan 53 alat. Ubah ukuran, kompres, konversi, tambahkan watermark, dan otomasi alur kerja gambar Anda tanpa mengirim data ke cloud.", + "Suite pemrosesan gambar self-hosted yang mengutamakan privasi dengan 70+ alat. Ubah ukuran, kompres, konversi, tambahkan watermark, dan otomasi alur kerja gambar Anda tanpa mengirim data ke cloud.", versionLabel: "Versi:", linksHeading: "Tautan", githubLink: "Repositori GitHub", @@ -1833,7 +2032,7 @@ export const id: TranslationKeys = { "Tanpa batas. Tanpa batasan tersembunyi.", "Berfungsi sepenuhnya offline.", "Pemrosesan batch tanpa batas.", - "53 alat pemrosesan gambar.", + "70+ alat pemrosesan gambar.", "15 model AI. Di perangkat Anda.", "Secepat kilat. Dibangun di atas Sharp.", "Siap untuk jaringan terisolasi.", diff --git a/packages/shared/src/i18n/it.ts b/packages/shared/src/i18n/it.ts index 2a80c095..71581e74 100644 --- a/packages/shared/src/i18n/it.ts +++ b/packages/shared/src/i18n/it.ts @@ -47,6 +47,10 @@ export const it: TranslationKeys = { utilities: "Utilita", layout: "Layout e composizione", format: "Formato e conversione", + video: "Video", + audio: "Audio", + documents: "PDF & Documents", + data: "Data Files", ai: "Strumenti IA", }, modalities: { @@ -276,6 +280,74 @@ export const it: TranslationKeys = { name: "Immagine a Base64", description: "Converti immagini in stringhe Base64 per incorporarle in HTML, CSS e altro", }, + "convert-video": { + name: "Convert Video", + description: "Convert videos between MP4, MOV, and WebM", + }, + "compress-video": { + name: "Compress Video", + description: "Shrink video file size with quality control", + }, + "trim-video": { + name: "Trim Video", + description: "Cut a clip out of a video", + }, + "mute-video": { + name: "Mute Video", + description: "Remove the audio track from a video", + }, + "video-to-gif": { + name: "Video to GIF", + description: "Turn a video clip into an animated GIF", + }, + "convert-audio": { + name: "Convert Audio", + description: "Convert audio between MP3, WAV, OGG, and more", + }, + "trim-audio": { + name: "Trim Audio", + description: "Cut a section out of an audio file", + }, + "extract-audio": { + name: "Extract Audio", + description: "Pull the audio track out of a video", + }, + "merge-pdf": { + name: "Merge PDFs", + description: "Combine multiple PDFs into one", + }, + "split-pdf": { + name: "Split PDF", + description: "Extract pages or split into parts", + }, + "compress-pdf": { + name: "Compress PDF", + description: "Shrink PDF file size", + }, + "rotate-pdf": { + name: "Rotate PDF", + description: "Rotate pages in a PDF", + }, + "word-to-pdf": { + name: "Word to PDF", + description: "Convert Word documents to PDF", + }, + "csv-excel": { + name: "CSV to Excel", + description: "Convert between CSV and Excel (XLSX), both directions", + }, + "csv-json": { + name: "CSV to JSON", + description: "Convert between CSV and JSON, both directions", + }, + "json-xml": { + name: "JSON to XML", + description: "Convert between JSON and XML, both directions", + }, + "split-csv": { + name: "Split CSV", + description: "Split a CSV into smaller files by row count", + }, pipeline: { name: "Costruttore di Pipeline", description: "Concatena piu strumenti in un flusso di lavoro", @@ -1238,6 +1310,133 @@ export const it: TranslationKeys = { balanced: "Bilanciato", best: "Ottimo", }, + "convert-video": { + format: "Output format", + quality: "Quality", + high: "High", + balanced: "Balanced", + small: "Smallest", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "compress-video": { + quality: "Compression", + light: "Light", + balanced: "Balanced", + strong: "Strong", + resolution: "Resolution", + original: "Original", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "trim-video": { + start: "Start (seconds)", + end: "End (seconds)", + precise: "Frame-accurate (re-encodes)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "mute-video": { + submit: "Remove audio", + submitBatch: "Remove audio ({count} files)", + progressLabel: "Muting", + }, + "video-to-gif": { + fps: "Frames per second", + width: "Width (px)", + start: "Start (seconds)", + duration: "Duration (seconds)", + submit: "Create GIF", + submitBatch: "Create GIFs ({count} files)", + progressLabel: "Creating GIF", + }, + "convert-audio": { + format: "Output format", + bitrate: "Bitrate (kbps)", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "trim-audio": { + start: "Start (seconds)", + end: "End (seconds)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "extract-audio": { + format: "Output format", + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, + "merge-pdf": { + addFiles: "Add PDFs to merge", + needTwo: "At least two PDFs are required", + orderHint: "{count} PDFs will merge in this order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "split-pdf": { + mode: "Split mode", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 7-z", + everyN: "Pages per part", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, + "compress-pdf": { + preset: "Quality preset", + screen: "Screen (smallest)", + ebook: "Ebook (balanced)", + printer: "Printer (best quality)", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "rotate-pdf": { + angle: "Rotation angle", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 1-z (all pages)", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "word-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-excel": { + sheet: "Worksheet number", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-json": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "json-xml": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "split-csv": { + rowsPerFile: "Rows per file", + keepHeader: "Keep header in each file", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, }, urlImport: { title: "Importa da URL", @@ -1793,7 +1992,7 @@ export const it: TranslationKeys = { heading: "Informazioni", appName: "SnapOtter", appDescription: - "Una suite di elaborazione immagini self-hosted e orientata alla privacy, con 53 strumenti. Ridimensiona, comprimi, converti, aggiungi filigrane e automatizza i tuoi flussi di lavoro senza inviare dati al cloud.", + "Una suite di elaborazione immagini self-hosted e orientata alla privacy, con 70+ strumenti. Ridimensiona, comprimi, converti, aggiungi filigrane e automatizza i tuoi flussi di lavoro senza inviare dati al cloud.", versionLabel: "Versione:", linksHeading: "Link", githubLink: "Repository GitHub", @@ -1840,7 +2039,7 @@ export const it: TranslationKeys = { "Nessun limite. Nessun tetto nascosto.", "Funziona completamente offline.", "Elaborazione in blocco illimitata.", - "53 strumenti per le immagini.", + "70+ strumenti per le immagini.", "15 modelli IA. Il tuo hardware.", "Velocissimo. Basato su Sharp.", "Pronto per reti isolate.", diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index 5d2f0263..c2851614 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -47,6 +47,10 @@ export const ja: TranslationKeys = { utilities: "ユーティリティ", layout: "レイアウトと構図", format: "フォーマットと変換", + video: "Video", + audio: "Audio", + documents: "PDF & Documents", + data: "Data Files", ai: "AIツール", }, modalities: { @@ -240,6 +244,74 @@ export const ja: TranslationKeys = { name: "画像からBase64", description: "画像をBase64文字列に変換してHTML、CSS等に埋め込み", }, + "convert-video": { + name: "Convert Video", + description: "Convert videos between MP4, MOV, and WebM", + }, + "compress-video": { + name: "Compress Video", + description: "Shrink video file size with quality control", + }, + "trim-video": { + name: "Trim Video", + description: "Cut a clip out of a video", + }, + "mute-video": { + name: "Mute Video", + description: "Remove the audio track from a video", + }, + "video-to-gif": { + name: "Video to GIF", + description: "Turn a video clip into an animated GIF", + }, + "convert-audio": { + name: "Convert Audio", + description: "Convert audio between MP3, WAV, OGG, and more", + }, + "trim-audio": { + name: "Trim Audio", + description: "Cut a section out of an audio file", + }, + "extract-audio": { + name: "Extract Audio", + description: "Pull the audio track out of a video", + }, + "merge-pdf": { + name: "Merge PDFs", + description: "Combine multiple PDFs into one", + }, + "split-pdf": { + name: "Split PDF", + description: "Extract pages or split into parts", + }, + "compress-pdf": { + name: "Compress PDF", + description: "Shrink PDF file size", + }, + "rotate-pdf": { + name: "Rotate PDF", + description: "Rotate pages in a PDF", + }, + "word-to-pdf": { + name: "Word to PDF", + description: "Convert Word documents to PDF", + }, + "csv-excel": { + name: "CSV to Excel", + description: "Convert between CSV and Excel (XLSX), both directions", + }, + "csv-json": { + name: "CSV to JSON", + description: "Convert between CSV and JSON, both directions", + }, + "json-xml": { + name: "JSON to XML", + description: "Convert between JSON and XML, both directions", + }, + "split-csv": { + name: "Split CSV", + description: "Split a CSV into smaller files by row count", + }, pipeline: { name: "Pipelineビルダー", description: "複数のツールをワークフローに連結" }, processing: { canceled: "Processing canceled" }, mediaPlayer: { @@ -1197,6 +1269,133 @@ export const ja: TranslationKeys = { balanced: "バランス", best: "最高", }, + "convert-video": { + format: "Output format", + quality: "Quality", + high: "High", + balanced: "Balanced", + small: "Smallest", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "compress-video": { + quality: "Compression", + light: "Light", + balanced: "Balanced", + strong: "Strong", + resolution: "Resolution", + original: "Original", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "trim-video": { + start: "Start (seconds)", + end: "End (seconds)", + precise: "Frame-accurate (re-encodes)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "mute-video": { + submit: "Remove audio", + submitBatch: "Remove audio ({count} files)", + progressLabel: "Muting", + }, + "video-to-gif": { + fps: "Frames per second", + width: "Width (px)", + start: "Start (seconds)", + duration: "Duration (seconds)", + submit: "Create GIF", + submitBatch: "Create GIFs ({count} files)", + progressLabel: "Creating GIF", + }, + "convert-audio": { + format: "Output format", + bitrate: "Bitrate (kbps)", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "trim-audio": { + start: "Start (seconds)", + end: "End (seconds)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "extract-audio": { + format: "Output format", + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, + "merge-pdf": { + addFiles: "Add PDFs to merge", + needTwo: "At least two PDFs are required", + orderHint: "{count} PDFs will merge in this order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "split-pdf": { + mode: "Split mode", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 7-z", + everyN: "Pages per part", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, + "compress-pdf": { + preset: "Quality preset", + screen: "Screen (smallest)", + ebook: "Ebook (balanced)", + printer: "Printer (best quality)", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "rotate-pdf": { + angle: "Rotation angle", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 1-z (all pages)", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "word-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-excel": { + sheet: "Worksheet number", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-json": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "json-xml": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "split-csv": { + rowsPerFile: "Rows per file", + keepHeader: "Keep header in each file", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, }, urlImport: { title: "URLからインポート", @@ -1744,7 +1943,7 @@ export const ja: TranslationKeys = { heading: "SnapOtterについて", appName: "SnapOtter", appDescription: - "セルフホスト型、プライバシー第一の画像処理スイート。53のツールを搭載。リサイズ、圧縮、変換、ウォーターマーク追加など、画像ワークフローをクラウドにデータを送ることなく自動化できます。", + "セルフホスト型、プライバシー第一の画像処理スイート。70+のツールを搭載。リサイズ、圧縮、変換、ウォーターマーク追加など、画像ワークフローをクラウドにデータを送ることなく自動化できます。", versionLabel: "バージョン:", linksHeading: "リンク", githubLink: "GitHubリポジトリ", @@ -1790,7 +1989,7 @@ export const ja: TranslationKeys = { "制限なし。隠れた上限なし。", "完全オフラインで動作。", "バッチ処理も無制限。", - "53の画像ツールを搭載。", + "70+の画像ツールを搭載。", "15のAIモデル。あなたのハードウェアで。", "Sharp搭載で超高速処理。", "エアギャップ環境にも対応。", diff --git a/packages/shared/src/i18n/ko.ts b/packages/shared/src/i18n/ko.ts index f827493c..6c41ca44 100644 --- a/packages/shared/src/i18n/ko.ts +++ b/packages/shared/src/i18n/ko.ts @@ -47,6 +47,10 @@ export const ko: TranslationKeys = { utilities: "유틸리티", layout: "레이아웃 및 구도", format: "포맷 및 변환", + video: "Video", + audio: "Audio", + documents: "PDF & Documents", + data: "Data Files", ai: "AI 도구", }, modalities: { @@ -227,6 +231,74 @@ export const ko: TranslationKeys = { name: "이미지를 Base64로", description: "이미지를 Base64 문자열로 변환하여 HTML, CSS 등에 임베드", }, + "convert-video": { + name: "Convert Video", + description: "Convert videos between MP4, MOV, and WebM", + }, + "compress-video": { + name: "Compress Video", + description: "Shrink video file size with quality control", + }, + "trim-video": { + name: "Trim Video", + description: "Cut a clip out of a video", + }, + "mute-video": { + name: "Mute Video", + description: "Remove the audio track from a video", + }, + "video-to-gif": { + name: "Video to GIF", + description: "Turn a video clip into an animated GIF", + }, + "convert-audio": { + name: "Convert Audio", + description: "Convert audio between MP3, WAV, OGG, and more", + }, + "trim-audio": { + name: "Trim Audio", + description: "Cut a section out of an audio file", + }, + "extract-audio": { + name: "Extract Audio", + description: "Pull the audio track out of a video", + }, + "merge-pdf": { + name: "Merge PDFs", + description: "Combine multiple PDFs into one", + }, + "split-pdf": { + name: "Split PDF", + description: "Extract pages or split into parts", + }, + "compress-pdf": { + name: "Compress PDF", + description: "Shrink PDF file size", + }, + "rotate-pdf": { + name: "Rotate PDF", + description: "Rotate pages in a PDF", + }, + "word-to-pdf": { + name: "Word to PDF", + description: "Convert Word documents to PDF", + }, + "csv-excel": { + name: "CSV to Excel", + description: "Convert between CSV and Excel (XLSX), both directions", + }, + "csv-json": { + name: "CSV to JSON", + description: "Convert between CSV and JSON, both directions", + }, + "json-xml": { + name: "JSON to XML", + description: "Convert between JSON and XML, both directions", + }, + "split-csv": { + name: "Split CSV", + description: "Split a CSV into smaller files by row count", + }, pipeline: { name: "Pipeline 빌더", description: "여러 도구를 워크플로로 연결" }, processing: { canceled: "Processing canceled" }, mediaPlayer: { @@ -1182,6 +1254,133 @@ export const ko: TranslationKeys = { balanced: "균형", best: "최고", }, + "convert-video": { + format: "Output format", + quality: "Quality", + high: "High", + balanced: "Balanced", + small: "Smallest", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "compress-video": { + quality: "Compression", + light: "Light", + balanced: "Balanced", + strong: "Strong", + resolution: "Resolution", + original: "Original", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "trim-video": { + start: "Start (seconds)", + end: "End (seconds)", + precise: "Frame-accurate (re-encodes)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "mute-video": { + submit: "Remove audio", + submitBatch: "Remove audio ({count} files)", + progressLabel: "Muting", + }, + "video-to-gif": { + fps: "Frames per second", + width: "Width (px)", + start: "Start (seconds)", + duration: "Duration (seconds)", + submit: "Create GIF", + submitBatch: "Create GIFs ({count} files)", + progressLabel: "Creating GIF", + }, + "convert-audio": { + format: "Output format", + bitrate: "Bitrate (kbps)", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "trim-audio": { + start: "Start (seconds)", + end: "End (seconds)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "extract-audio": { + format: "Output format", + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, + "merge-pdf": { + addFiles: "Add PDFs to merge", + needTwo: "At least two PDFs are required", + orderHint: "{count} PDFs will merge in this order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "split-pdf": { + mode: "Split mode", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 7-z", + everyN: "Pages per part", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, + "compress-pdf": { + preset: "Quality preset", + screen: "Screen (smallest)", + ebook: "Ebook (balanced)", + printer: "Printer (best quality)", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "rotate-pdf": { + angle: "Rotation angle", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 1-z (all pages)", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "word-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-excel": { + sheet: "Worksheet number", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-json": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "json-xml": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "split-csv": { + rowsPerFile: "Rows per file", + keepHeader: "Keep header in each file", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, }, urlImport: { title: "URL에서 가져오기", @@ -1729,7 +1928,7 @@ export const ko: TranslationKeys = { heading: "정보", appName: "SnapOtter", appDescription: - "셀프 호스팅, 개인정보 우선의 이미지 처리 도구 모음으로 53개의 도구를 제공합니다. 리사이즈, 압축, 변환, 워터마크 추가 등 이미지 워크플로를 클라우드에 데이터를 보내지 않고 자동화할 수 있습니다.", + "셀프 호스팅, 개인정보 우선의 이미지 처리 도구 모음으로 70+개의 도구를 제공합니다. 리사이즈, 압축, 변환, 워터마크 추가 등 이미지 워크플로를 클라우드에 데이터를 보내지 않고 자동화할 수 있습니다.", versionLabel: "버전:", linksHeading: "링크", githubLink: "GitHub 저장소", @@ -1775,7 +1974,7 @@ export const ko: TranslationKeys = { "제한 없음. 숨겨진 상한 없음.", "완전히 오프라인으로 작동해요.", "무제한 일괄 처리.", - "53개의 이미지 도구.", + "70+개의 이미지 도구.", "15개 AI 모델. 당신의 하드웨어에서.", "Sharp 기반의 초고속 처리.", "에어갭 환경에서도 사용 가능.", diff --git a/packages/shared/src/i18n/nl.ts b/packages/shared/src/i18n/nl.ts index 7371b0b1..9676adf0 100644 --- a/packages/shared/src/i18n/nl.ts +++ b/packages/shared/src/i18n/nl.ts @@ -47,6 +47,10 @@ export const nl: TranslationKeys = { utilities: "Hulptools", layout: "Lay-out & Compositie", format: "Formaat & Conversie", + video: "Video", + audio: "Audio", + documents: "PDF & Documents", + data: "Data Files", ai: "AI-tools", }, modalities: { @@ -277,6 +281,74 @@ export const nl: TranslationKeys = { name: "Afbeelding naar Base64", description: "Afbeeldingen converteren naar Base64-strings voor gebruik in HTML, CSS en meer", }, + "convert-video": { + name: "Convert Video", + description: "Convert videos between MP4, MOV, and WebM", + }, + "compress-video": { + name: "Compress Video", + description: "Shrink video file size with quality control", + }, + "trim-video": { + name: "Trim Video", + description: "Cut a clip out of a video", + }, + "mute-video": { + name: "Mute Video", + description: "Remove the audio track from a video", + }, + "video-to-gif": { + name: "Video to GIF", + description: "Turn a video clip into an animated GIF", + }, + "convert-audio": { + name: "Convert Audio", + description: "Convert audio between MP3, WAV, OGG, and more", + }, + "trim-audio": { + name: "Trim Audio", + description: "Cut a section out of an audio file", + }, + "extract-audio": { + name: "Extract Audio", + description: "Pull the audio track out of a video", + }, + "merge-pdf": { + name: "Merge PDFs", + description: "Combine multiple PDFs into one", + }, + "split-pdf": { + name: "Split PDF", + description: "Extract pages or split into parts", + }, + "compress-pdf": { + name: "Compress PDF", + description: "Shrink PDF file size", + }, + "rotate-pdf": { + name: "Rotate PDF", + description: "Rotate pages in a PDF", + }, + "word-to-pdf": { + name: "Word to PDF", + description: "Convert Word documents to PDF", + }, + "csv-excel": { + name: "CSV to Excel", + description: "Convert between CSV and Excel (XLSX), both directions", + }, + "csv-json": { + name: "CSV to JSON", + description: "Convert between CSV and JSON, both directions", + }, + "json-xml": { + name: "JSON to XML", + description: "Convert between JSON and XML, both directions", + }, + "split-csv": { + name: "Split CSV", + description: "Split a CSV into smaller files by row count", + }, pipeline: { name: "Pipeline-builder", description: "Meerdere tools koppelen tot een workflow", @@ -1241,6 +1313,133 @@ export const nl: TranslationKeys = { balanced: "Gebalanceerd", best: "Beste", }, + "convert-video": { + format: "Output format", + quality: "Quality", + high: "High", + balanced: "Balanced", + small: "Smallest", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "compress-video": { + quality: "Compression", + light: "Light", + balanced: "Balanced", + strong: "Strong", + resolution: "Resolution", + original: "Original", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "trim-video": { + start: "Start (seconds)", + end: "End (seconds)", + precise: "Frame-accurate (re-encodes)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "mute-video": { + submit: "Remove audio", + submitBatch: "Remove audio ({count} files)", + progressLabel: "Muting", + }, + "video-to-gif": { + fps: "Frames per second", + width: "Width (px)", + start: "Start (seconds)", + duration: "Duration (seconds)", + submit: "Create GIF", + submitBatch: "Create GIFs ({count} files)", + progressLabel: "Creating GIF", + }, + "convert-audio": { + format: "Output format", + bitrate: "Bitrate (kbps)", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "trim-audio": { + start: "Start (seconds)", + end: "End (seconds)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "extract-audio": { + format: "Output format", + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, + "merge-pdf": { + addFiles: "Add PDFs to merge", + needTwo: "At least two PDFs are required", + orderHint: "{count} PDFs will merge in this order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "split-pdf": { + mode: "Split mode", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 7-z", + everyN: "Pages per part", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, + "compress-pdf": { + preset: "Quality preset", + screen: "Screen (smallest)", + ebook: "Ebook (balanced)", + printer: "Printer (best quality)", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "rotate-pdf": { + angle: "Rotation angle", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 1-z (all pages)", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "word-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-excel": { + sheet: "Worksheet number", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-json": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "json-xml": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "split-csv": { + rowsPerFile: "Rows per file", + keepHeader: "Keep header in each file", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, }, urlImport: { title: "Importeren van URLs", @@ -1790,7 +1989,7 @@ export const nl: TranslationKeys = { heading: "Over", appName: "SnapOtter", appDescription: - "Een zelf-gehoste, privacy-first beeldverwerkingssuite met 50+ tools. Schalen, comprimeren, converteren, watermerken en je beeldworkflows automatiseren zonder data naar de cloud te sturen.", + "Een zelf-gehoste, privacy-first beeldverwerkingssuite met 70+ tools. Schalen, comprimeren, converteren, watermerken en je beeldworkflows automatiseren zonder data naar de cloud te sturen.", versionLabel: "Versie:", linksHeading: "Links", githubLink: "GitHub-repository", @@ -1836,7 +2035,7 @@ export const nl: TranslationKeys = { "Geen limieten. Geen verborgen plafonds.", "Werkt volledig offline.", "Onbeperkte batchverwerking.", - "51 afbeeldingstools.", + "70+ tools.", "16 AI-modellen. Jouw hardware.", "Razendsnel. Gebouwd op Sharp.", "Geschikt voor air-gapped netwerken.", diff --git a/packages/shared/src/i18n/pl.ts b/packages/shared/src/i18n/pl.ts index d769944b..6096b843 100644 --- a/packages/shared/src/i18n/pl.ts +++ b/packages/shared/src/i18n/pl.ts @@ -47,6 +47,10 @@ export const pl: TranslationKeys = { utilities: "Narzędzia", layout: "Układ i kompozycja", format: "Format i konwersja", + video: "Video", + audio: "Audio", + documents: "PDF & Documents", + data: "Data Files", ai: "Narzędzia AI", }, modalities: { @@ -278,6 +282,74 @@ export const pl: TranslationKeys = { name: "Obraz do Base64", description: "Konwersja obrazów na ciągi Base64 do osadzania w HTML, CSS i nie tylko", }, + "convert-video": { + name: "Convert Video", + description: "Convert videos between MP4, MOV, and WebM", + }, + "compress-video": { + name: "Compress Video", + description: "Shrink video file size with quality control", + }, + "trim-video": { + name: "Trim Video", + description: "Cut a clip out of a video", + }, + "mute-video": { + name: "Mute Video", + description: "Remove the audio track from a video", + }, + "video-to-gif": { + name: "Video to GIF", + description: "Turn a video clip into an animated GIF", + }, + "convert-audio": { + name: "Convert Audio", + description: "Convert audio between MP3, WAV, OGG, and more", + }, + "trim-audio": { + name: "Trim Audio", + description: "Cut a section out of an audio file", + }, + "extract-audio": { + name: "Extract Audio", + description: "Pull the audio track out of a video", + }, + "merge-pdf": { + name: "Merge PDFs", + description: "Combine multiple PDFs into one", + }, + "split-pdf": { + name: "Split PDF", + description: "Extract pages or split into parts", + }, + "compress-pdf": { + name: "Compress PDF", + description: "Shrink PDF file size", + }, + "rotate-pdf": { + name: "Rotate PDF", + description: "Rotate pages in a PDF", + }, + "word-to-pdf": { + name: "Word to PDF", + description: "Convert Word documents to PDF", + }, + "csv-excel": { + name: "CSV to Excel", + description: "Convert between CSV and Excel (XLSX), both directions", + }, + "csv-json": { + name: "CSV to JSON", + description: "Convert between CSV and JSON, both directions", + }, + "json-xml": { + name: "JSON to XML", + description: "Convert between JSON and XML, both directions", + }, + "split-csv": { + name: "Split CSV", + description: "Split a CSV into smaller files by row count", + }, pipeline: { name: "Konstruktor Pipeline", description: "Łączenie wielu narzędzi w przepływ pracy", @@ -1242,6 +1314,133 @@ export const pl: TranslationKeys = { balanced: "Zrównoważony", best: "Najlepszy", }, + "convert-video": { + format: "Output format", + quality: "Quality", + high: "High", + balanced: "Balanced", + small: "Smallest", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "compress-video": { + quality: "Compression", + light: "Light", + balanced: "Balanced", + strong: "Strong", + resolution: "Resolution", + original: "Original", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "trim-video": { + start: "Start (seconds)", + end: "End (seconds)", + precise: "Frame-accurate (re-encodes)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "mute-video": { + submit: "Remove audio", + submitBatch: "Remove audio ({count} files)", + progressLabel: "Muting", + }, + "video-to-gif": { + fps: "Frames per second", + width: "Width (px)", + start: "Start (seconds)", + duration: "Duration (seconds)", + submit: "Create GIF", + submitBatch: "Create GIFs ({count} files)", + progressLabel: "Creating GIF", + }, + "convert-audio": { + format: "Output format", + bitrate: "Bitrate (kbps)", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "trim-audio": { + start: "Start (seconds)", + end: "End (seconds)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "extract-audio": { + format: "Output format", + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, + "merge-pdf": { + addFiles: "Add PDFs to merge", + needTwo: "At least two PDFs are required", + orderHint: "{count} PDFs will merge in this order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "split-pdf": { + mode: "Split mode", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 7-z", + everyN: "Pages per part", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, + "compress-pdf": { + preset: "Quality preset", + screen: "Screen (smallest)", + ebook: "Ebook (balanced)", + printer: "Printer (best quality)", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "rotate-pdf": { + angle: "Rotation angle", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 1-z (all pages)", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "word-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-excel": { + sheet: "Worksheet number", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-json": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "json-xml": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "split-csv": { + rowsPerFile: "Rows per file", + keepHeader: "Keep header in each file", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, }, urlImport: { title: "Import z adresów URL", @@ -1796,7 +1995,7 @@ export const pl: TranslationKeys = { heading: "Informacje", appName: "SnapOtter", appDescription: - "Samoobsługowy pakiet narzędzi do przetwarzania obrazów z priorytetem prywatności. 53 narzędzi. Zmiana rozmiaru, kompresja, konwersja, znaki wodne i automatyzacja procesów bez wysyłania danych do chmury.", + "Samoobsługowy pakiet narzędzi do przetwarzania obrazów z priorytetem prywatności. 70+ narzędzi. Zmiana rozmiaru, kompresja, konwersja, znaki wodne i automatyzacja procesów bez wysyłania danych do chmury.", versionLabel: "Wersja:", linksHeading: "Odnośniki", githubLink: "Repozytorium GitHub", @@ -1843,7 +2042,7 @@ export const pl: TranslationKeys = { "Bez ograniczeń. Bez ukrytych limitów.", "Działa w pełni offline.", "Przetwarzanie wsadowe bez ograniczeń.", - "53 narzędzi do obrazów.", + "70+ narzędzi do obrazów.", "15 modeli AI. Na Twoim sprzęcie.", "Błyskawiczna szybkość. Oparte na Sharp.", "Gotowe do sieci izolowanych.", diff --git a/packages/shared/src/i18n/pt-BR.ts b/packages/shared/src/i18n/pt-BR.ts index fda71e68..ed5b3a0d 100644 --- a/packages/shared/src/i18n/pt-BR.ts +++ b/packages/shared/src/i18n/pt-BR.ts @@ -47,6 +47,10 @@ export const ptBR: TranslationKeys = { utilities: "Utilitarios", layout: "Layout e composicao", format: "Formato e conversao", + video: "Video", + audio: "Audio", + documents: "PDF & Documents", + data: "Data Files", ai: "Ferramentas de AI", }, modalities: { @@ -275,6 +279,74 @@ export const ptBR: TranslationKeys = { name: "Imagem para Base64", description: "Converta imagens em strings Base64 para incorporar em HTML, CSS e mais", }, + "convert-video": { + name: "Convert Video", + description: "Convert videos between MP4, MOV, and WebM", + }, + "compress-video": { + name: "Compress Video", + description: "Shrink video file size with quality control", + }, + "trim-video": { + name: "Trim Video", + description: "Cut a clip out of a video", + }, + "mute-video": { + name: "Mute Video", + description: "Remove the audio track from a video", + }, + "video-to-gif": { + name: "Video to GIF", + description: "Turn a video clip into an animated GIF", + }, + "convert-audio": { + name: "Convert Audio", + description: "Convert audio between MP3, WAV, OGG, and more", + }, + "trim-audio": { + name: "Trim Audio", + description: "Cut a section out of an audio file", + }, + "extract-audio": { + name: "Extract Audio", + description: "Pull the audio track out of a video", + }, + "merge-pdf": { + name: "Merge PDFs", + description: "Combine multiple PDFs into one", + }, + "split-pdf": { + name: "Split PDF", + description: "Extract pages or split into parts", + }, + "compress-pdf": { + name: "Compress PDF", + description: "Shrink PDF file size", + }, + "rotate-pdf": { + name: "Rotate PDF", + description: "Rotate pages in a PDF", + }, + "word-to-pdf": { + name: "Word to PDF", + description: "Convert Word documents to PDF", + }, + "csv-excel": { + name: "CSV to Excel", + description: "Convert between CSV and Excel (XLSX), both directions", + }, + "csv-json": { + name: "CSV to JSON", + description: "Convert between CSV and JSON, both directions", + }, + "json-xml": { + name: "JSON to XML", + description: "Convert between JSON and XML, both directions", + }, + "split-csv": { + name: "Split CSV", + description: "Split a CSV into smaller files by row count", + }, pipeline: { name: "Construtor de Pipeline", description: "Encadeie varias ferramentas em um fluxo de trabalho", @@ -1238,6 +1310,133 @@ export const ptBR: TranslationKeys = { balanced: "Equilibrado", best: "Melhor", }, + "convert-video": { + format: "Output format", + quality: "Quality", + high: "High", + balanced: "Balanced", + small: "Smallest", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "compress-video": { + quality: "Compression", + light: "Light", + balanced: "Balanced", + strong: "Strong", + resolution: "Resolution", + original: "Original", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "trim-video": { + start: "Start (seconds)", + end: "End (seconds)", + precise: "Frame-accurate (re-encodes)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "mute-video": { + submit: "Remove audio", + submitBatch: "Remove audio ({count} files)", + progressLabel: "Muting", + }, + "video-to-gif": { + fps: "Frames per second", + width: "Width (px)", + start: "Start (seconds)", + duration: "Duration (seconds)", + submit: "Create GIF", + submitBatch: "Create GIFs ({count} files)", + progressLabel: "Creating GIF", + }, + "convert-audio": { + format: "Output format", + bitrate: "Bitrate (kbps)", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "trim-audio": { + start: "Start (seconds)", + end: "End (seconds)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "extract-audio": { + format: "Output format", + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, + "merge-pdf": { + addFiles: "Add PDFs to merge", + needTwo: "At least two PDFs are required", + orderHint: "{count} PDFs will merge in this order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "split-pdf": { + mode: "Split mode", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 7-z", + everyN: "Pages per part", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, + "compress-pdf": { + preset: "Quality preset", + screen: "Screen (smallest)", + ebook: "Ebook (balanced)", + printer: "Printer (best quality)", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "rotate-pdf": { + angle: "Rotation angle", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 1-z (all pages)", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "word-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-excel": { + sheet: "Worksheet number", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-json": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "json-xml": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "split-csv": { + rowsPerFile: "Rows per file", + keepHeader: "Keep header in each file", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, }, urlImport: { title: "Importar de URLs", @@ -1789,7 +1988,7 @@ export const ptBR: TranslationKeys = { heading: "Sobre", appName: "SnapOtter", appDescription: - "Uma suite de processamento de imagens auto-hospedada e focada em privacidade, com 53 ferramentas. Redimensione, comprima, converta, adicione marcas d'agua e automatize seus fluxos de trabalho de imagens sem enviar dados para a nuvem.", + "Uma suite de processamento de imagens auto-hospedada e focada em privacidade, com 70+ ferramentas. Redimensione, comprima, converta, adicione marcas d'agua e automatize seus fluxos de trabalho de imagens sem enviar dados para a nuvem.", versionLabel: "Versao:", linksHeading: "Links", githubLink: "Repositorio no GitHub", @@ -1837,7 +2036,7 @@ export const ptBR: TranslationKeys = { "Sem limites. Sem tetos ocultos.", "Funciona totalmente offline.", "Processamento em lote ilimitado.", - "53 ferramentas de imagem.", + "70+ ferramentas de imagem.", "15 modelos AI. Seu hardware.", "Ultra rapido. Construido sobre Sharp.", "Pronto para redes isoladas.", diff --git a/packages/shared/src/i18n/ru.ts b/packages/shared/src/i18n/ru.ts index dfe1c71d..e9548ea1 100644 --- a/packages/shared/src/i18n/ru.ts +++ b/packages/shared/src/i18n/ru.ts @@ -47,6 +47,10 @@ export const ru: TranslationKeys = { utilities: "Утилиты", layout: "Компоновка и композиция", format: "Формат и конвертация", + video: "Video", + audio: "Audio", + documents: "PDF & Documents", + data: "Data Files", ai: "AI-инструменты", }, modalities: { @@ -277,6 +281,74 @@ export const ru: TranslationKeys = { name: "Изображение в Base64", description: "Конвертация изображений в строки Base64 для встраивания в HTML, CSS и другое", }, + "convert-video": { + name: "Convert Video", + description: "Convert videos between MP4, MOV, and WebM", + }, + "compress-video": { + name: "Compress Video", + description: "Shrink video file size with quality control", + }, + "trim-video": { + name: "Trim Video", + description: "Cut a clip out of a video", + }, + "mute-video": { + name: "Mute Video", + description: "Remove the audio track from a video", + }, + "video-to-gif": { + name: "Video to GIF", + description: "Turn a video clip into an animated GIF", + }, + "convert-audio": { + name: "Convert Audio", + description: "Convert audio between MP3, WAV, OGG, and more", + }, + "trim-audio": { + name: "Trim Audio", + description: "Cut a section out of an audio file", + }, + "extract-audio": { + name: "Extract Audio", + description: "Pull the audio track out of a video", + }, + "merge-pdf": { + name: "Merge PDFs", + description: "Combine multiple PDFs into one", + }, + "split-pdf": { + name: "Split PDF", + description: "Extract pages or split into parts", + }, + "compress-pdf": { + name: "Compress PDF", + description: "Shrink PDF file size", + }, + "rotate-pdf": { + name: "Rotate PDF", + description: "Rotate pages in a PDF", + }, + "word-to-pdf": { + name: "Word to PDF", + description: "Convert Word documents to PDF", + }, + "csv-excel": { + name: "CSV to Excel", + description: "Convert between CSV and Excel (XLSX), both directions", + }, + "csv-json": { + name: "CSV to JSON", + description: "Convert between CSV and JSON, both directions", + }, + "json-xml": { + name: "JSON to XML", + description: "Convert between JSON and XML, both directions", + }, + "split-csv": { + name: "Split CSV", + description: "Split a CSV into smaller files by row count", + }, pipeline: { name: "Конструктор Pipeline", description: "Объединение нескольких инструментов в рабочий процесс", @@ -1240,6 +1312,133 @@ export const ru: TranslationKeys = { balanced: "Сбалансированная", best: "Лучшая", }, + "convert-video": { + format: "Output format", + quality: "Quality", + high: "High", + balanced: "Balanced", + small: "Smallest", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "compress-video": { + quality: "Compression", + light: "Light", + balanced: "Balanced", + strong: "Strong", + resolution: "Resolution", + original: "Original", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "trim-video": { + start: "Start (seconds)", + end: "End (seconds)", + precise: "Frame-accurate (re-encodes)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "mute-video": { + submit: "Remove audio", + submitBatch: "Remove audio ({count} files)", + progressLabel: "Muting", + }, + "video-to-gif": { + fps: "Frames per second", + width: "Width (px)", + start: "Start (seconds)", + duration: "Duration (seconds)", + submit: "Create GIF", + submitBatch: "Create GIFs ({count} files)", + progressLabel: "Creating GIF", + }, + "convert-audio": { + format: "Output format", + bitrate: "Bitrate (kbps)", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "trim-audio": { + start: "Start (seconds)", + end: "End (seconds)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "extract-audio": { + format: "Output format", + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, + "merge-pdf": { + addFiles: "Add PDFs to merge", + needTwo: "At least two PDFs are required", + orderHint: "{count} PDFs will merge in this order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "split-pdf": { + mode: "Split mode", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 7-z", + everyN: "Pages per part", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, + "compress-pdf": { + preset: "Quality preset", + screen: "Screen (smallest)", + ebook: "Ebook (balanced)", + printer: "Printer (best quality)", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "rotate-pdf": { + angle: "Rotation angle", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 1-z (all pages)", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "word-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-excel": { + sheet: "Worksheet number", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-json": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "json-xml": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "split-csv": { + rowsPerFile: "Rows per file", + keepHeader: "Keep header in each file", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, }, urlImport: { title: "Импорт по URL", @@ -1789,7 +1988,7 @@ export const ru: TranslationKeys = { heading: "О программе", appName: "SnapOtter", appDescription: - "Самостоятельно размещаемый набор инструментов для обработки изображений с приоритетом конфиденциальности. 53 инструмента. Изменение размера, сжатие, конвертация, водяные знаки и автоматизация рабочих процессов без отправки данных в облако.", + "Самостоятельно размещаемый набор инструментов для обработки изображений с приоритетом конфиденциальности. 70+ инструмента. Изменение размера, сжатие, конвертация, водяные знаки и автоматизация рабочих процессов без отправки данных в облако.", versionLabel: "Версия:", linksHeading: "Ссылки", githubLink: "Репозиторий GitHub", @@ -1835,7 +2034,7 @@ export const ru: TranslationKeys = { "Без ограничений. Без скрытых лимитов.", "Работает полностью офлайн.", "Пакетная обработка без ограничений.", - "53 инструмента для изображений.", + "70+ инструмента для изображений.", "16 AI-моделей. На Вашем оборудовании.", "Молниеносная скорость. На базе Sharp.", "Готов к изолированным сетям.", diff --git a/packages/shared/src/i18n/sv.ts b/packages/shared/src/i18n/sv.ts index 6d2d5305..7cadcc2d 100644 --- a/packages/shared/src/i18n/sv.ts +++ b/packages/shared/src/i18n/sv.ts @@ -47,6 +47,10 @@ export const sv: TranslationKeys = { utilities: "Verktyg", layout: "Layout & Komposition", format: "Format & Konvertering", + video: "Video", + audio: "Audio", + documents: "PDF & Documents", + data: "Data Files", ai: "AI-verktyg", }, modalities: { @@ -275,6 +279,74 @@ export const sv: TranslationKeys = { name: "Bild till Base64", description: "Konvertera bilder till Base64-strangar for inbaddning i HTML, CSS och mer", }, + "convert-video": { + name: "Convert Video", + description: "Convert videos between MP4, MOV, and WebM", + }, + "compress-video": { + name: "Compress Video", + description: "Shrink video file size with quality control", + }, + "trim-video": { + name: "Trim Video", + description: "Cut a clip out of a video", + }, + "mute-video": { + name: "Mute Video", + description: "Remove the audio track from a video", + }, + "video-to-gif": { + name: "Video to GIF", + description: "Turn a video clip into an animated GIF", + }, + "convert-audio": { + name: "Convert Audio", + description: "Convert audio between MP3, WAV, OGG, and more", + }, + "trim-audio": { + name: "Trim Audio", + description: "Cut a section out of an audio file", + }, + "extract-audio": { + name: "Extract Audio", + description: "Pull the audio track out of a video", + }, + "merge-pdf": { + name: "Merge PDFs", + description: "Combine multiple PDFs into one", + }, + "split-pdf": { + name: "Split PDF", + description: "Extract pages or split into parts", + }, + "compress-pdf": { + name: "Compress PDF", + description: "Shrink PDF file size", + }, + "rotate-pdf": { + name: "Rotate PDF", + description: "Rotate pages in a PDF", + }, + "word-to-pdf": { + name: "Word to PDF", + description: "Convert Word documents to PDF", + }, + "csv-excel": { + name: "CSV to Excel", + description: "Convert between CSV and Excel (XLSX), both directions", + }, + "csv-json": { + name: "CSV to JSON", + description: "Convert between CSV and JSON, both directions", + }, + "json-xml": { + name: "JSON to XML", + description: "Convert between JSON and XML, both directions", + }, + "split-csv": { + name: "Split CSV", + description: "Split a CSV into smaller files by row count", + }, pipeline: { name: "Pipeline-byggare", description: "Kedja samman flera verktyg till ett arbetsflode", @@ -1237,6 +1309,133 @@ export const sv: TranslationKeys = { balanced: "Balanserad", best: "Basta", }, + "convert-video": { + format: "Output format", + quality: "Quality", + high: "High", + balanced: "Balanced", + small: "Smallest", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "compress-video": { + quality: "Compression", + light: "Light", + balanced: "Balanced", + strong: "Strong", + resolution: "Resolution", + original: "Original", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "trim-video": { + start: "Start (seconds)", + end: "End (seconds)", + precise: "Frame-accurate (re-encodes)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "mute-video": { + submit: "Remove audio", + submitBatch: "Remove audio ({count} files)", + progressLabel: "Muting", + }, + "video-to-gif": { + fps: "Frames per second", + width: "Width (px)", + start: "Start (seconds)", + duration: "Duration (seconds)", + submit: "Create GIF", + submitBatch: "Create GIFs ({count} files)", + progressLabel: "Creating GIF", + }, + "convert-audio": { + format: "Output format", + bitrate: "Bitrate (kbps)", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "trim-audio": { + start: "Start (seconds)", + end: "End (seconds)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "extract-audio": { + format: "Output format", + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, + "merge-pdf": { + addFiles: "Add PDFs to merge", + needTwo: "At least two PDFs are required", + orderHint: "{count} PDFs will merge in this order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "split-pdf": { + mode: "Split mode", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 7-z", + everyN: "Pages per part", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, + "compress-pdf": { + preset: "Quality preset", + screen: "Screen (smallest)", + ebook: "Ebook (balanced)", + printer: "Printer (best quality)", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "rotate-pdf": { + angle: "Rotation angle", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 1-z (all pages)", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "word-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-excel": { + sheet: "Worksheet number", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-json": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "json-xml": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "split-csv": { + rowsPerFile: "Rows per file", + keepHeader: "Keep header in each file", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, }, urlImport: { title: "Importera fran URL:er", @@ -1785,7 +1984,7 @@ export const sv: TranslationKeys = { heading: "Om", appName: "SnapOtter", appDescription: - "En sjalvhostad, integritetsfokuserad bildbehandlingssvit med 53 verktyg. Skala, komprimera, konvertera, vattenmarkera och automatisera dina bildarbetsfloden utan att skicka data till molnet.", + "En sjalvhostad, integritetsfokuserad bildbehandlingssvit med 70+ verktyg. Skala, komprimera, konvertera, vattenmarkera och automatisera dina bildarbetsfloden utan att skicka data till molnet.", versionLabel: "Version:", linksHeading: "Lankar", githubLink: "GitHub-arkiv", @@ -1831,7 +2030,7 @@ export const sv: TranslationKeys = { "Inga granser. Inga dolda tak.", "Fungerar helt offline.", "Obegransad batchbearbetning.", - "53 bildverktyg.", + "70+ bildverktyg.", "16 AI-modeller. Din hardvara.", "Blixtsnabb. Byggd pa Sharp.", "Fungerar i slutna natverk.", diff --git a/packages/shared/src/i18n/th.ts b/packages/shared/src/i18n/th.ts index b7dd00b7..35ff4d09 100644 --- a/packages/shared/src/i18n/th.ts +++ b/packages/shared/src/i18n/th.ts @@ -47,6 +47,10 @@ export const th: TranslationKeys = { utilities: "เครื่องมือเสริม", layout: "เค้าโครงและการจัดองค์ประกอบ", format: "รูปแบบและการแปลง", + video: "Video", + audio: "Audio", + documents: "PDF & Documents", + data: "Data Files", ai: "เครื่องมือ AI", }, modalities: { @@ -270,6 +274,74 @@ export const th: TranslationKeys = { name: "ภาพเป็น Base64", description: "แปลงภาพเป็นสตริง Base64 สำหรับฝังใน HTML, CSS และอื่นๆ", }, + "convert-video": { + name: "Convert Video", + description: "Convert videos between MP4, MOV, and WebM", + }, + "compress-video": { + name: "Compress Video", + description: "Shrink video file size with quality control", + }, + "trim-video": { + name: "Trim Video", + description: "Cut a clip out of a video", + }, + "mute-video": { + name: "Mute Video", + description: "Remove the audio track from a video", + }, + "video-to-gif": { + name: "Video to GIF", + description: "Turn a video clip into an animated GIF", + }, + "convert-audio": { + name: "Convert Audio", + description: "Convert audio between MP3, WAV, OGG, and more", + }, + "trim-audio": { + name: "Trim Audio", + description: "Cut a section out of an audio file", + }, + "extract-audio": { + name: "Extract Audio", + description: "Pull the audio track out of a video", + }, + "merge-pdf": { + name: "Merge PDFs", + description: "Combine multiple PDFs into one", + }, + "split-pdf": { + name: "Split PDF", + description: "Extract pages or split into parts", + }, + "compress-pdf": { + name: "Compress PDF", + description: "Shrink PDF file size", + }, + "rotate-pdf": { + name: "Rotate PDF", + description: "Rotate pages in a PDF", + }, + "word-to-pdf": { + name: "Word to PDF", + description: "Convert Word documents to PDF", + }, + "csv-excel": { + name: "CSV to Excel", + description: "Convert between CSV and Excel (XLSX), both directions", + }, + "csv-json": { + name: "CSV to JSON", + description: "Convert between CSV and JSON, both directions", + }, + "json-xml": { + name: "JSON to XML", + description: "Convert between JSON and XML, both directions", + }, + "split-csv": { + name: "Split CSV", + description: "Split a CSV into smaller files by row count", + }, pipeline: { name: "ตัวสร้าง Pipeline", description: "เชื่อมต่อหลายเครื่องมือเป็นขั้นตอนทำงาน", @@ -1220,6 +1292,133 @@ export const th: TranslationKeys = { balanced: "สมดุล", best: "ดีที่สุด", }, + "convert-video": { + format: "Output format", + quality: "Quality", + high: "High", + balanced: "Balanced", + small: "Smallest", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "compress-video": { + quality: "Compression", + light: "Light", + balanced: "Balanced", + strong: "Strong", + resolution: "Resolution", + original: "Original", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "trim-video": { + start: "Start (seconds)", + end: "End (seconds)", + precise: "Frame-accurate (re-encodes)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "mute-video": { + submit: "Remove audio", + submitBatch: "Remove audio ({count} files)", + progressLabel: "Muting", + }, + "video-to-gif": { + fps: "Frames per second", + width: "Width (px)", + start: "Start (seconds)", + duration: "Duration (seconds)", + submit: "Create GIF", + submitBatch: "Create GIFs ({count} files)", + progressLabel: "Creating GIF", + }, + "convert-audio": { + format: "Output format", + bitrate: "Bitrate (kbps)", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "trim-audio": { + start: "Start (seconds)", + end: "End (seconds)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "extract-audio": { + format: "Output format", + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, + "merge-pdf": { + addFiles: "Add PDFs to merge", + needTwo: "At least two PDFs are required", + orderHint: "{count} PDFs will merge in this order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "split-pdf": { + mode: "Split mode", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 7-z", + everyN: "Pages per part", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, + "compress-pdf": { + preset: "Quality preset", + screen: "Screen (smallest)", + ebook: "Ebook (balanced)", + printer: "Printer (best quality)", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "rotate-pdf": { + angle: "Rotation angle", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 1-z (all pages)", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "word-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-excel": { + sheet: "Worksheet number", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-json": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "json-xml": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "split-csv": { + rowsPerFile: "Rows per file", + keepHeader: "Keep header in each file", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, }, urlImport: { title: "นำเข้าจาก URL", @@ -1763,7 +1962,7 @@ export const th: TranslationKeys = { heading: "เกี่ยวกับ", appName: "SnapOtter", appDescription: - "ชุดเครื่องมือประมวลผลภาพแบบโฮสต์เองที่เน้นความเป็นส่วนตัวพร้อม 53 เครื่องมือ ปรับขนาด บีบอัด แปลง เพิ่มลายน้ำ และทำงานอัตโนมัติโดยไม่ต้องส่งข้อมูลไปคลาวด์", + "ชุดเครื่องมือประมวลผลภาพแบบโฮสต์เองที่เน้นความเป็นส่วนตัวพร้อม 70+ เครื่องมือ ปรับขนาด บีบอัด แปลง เพิ่มลายน้ำ และทำงานอัตโนมัติโดยไม่ต้องส่งข้อมูลไปคลาวด์", versionLabel: "เวอร์ชัน:", linksHeading: "ลิงก์", githubLink: "คลังเก็บโค้ด GitHub", @@ -1808,7 +2007,7 @@ export const th: TranslationKeys = { "ไม่จำกัด ไม่มีเพดานซ่อน", "ใช้งานออฟไลน์ได้เต็มที่", "ประมวลผลเป็นชุดไม่จำกัด", - "53 เครื่องมือจัดการภาพ", + "70+ เครื่องมือจัดการภาพ", "15 โมเดล AI บนฮาร์ดแวร์ของคุณ", "เร็วสายฟ้าแลบ สร้างบน Sharp", "พร้อมใช้ในเครือข่ายปิด", diff --git a/packages/shared/src/i18n/tr.ts b/packages/shared/src/i18n/tr.ts index 6620fcd2..eec43d2d 100644 --- a/packages/shared/src/i18n/tr.ts +++ b/packages/shared/src/i18n/tr.ts @@ -47,6 +47,10 @@ export const tr: TranslationKeys = { utilities: "Yardımcı Araçlar", layout: "Düzen ve Kompozisyon", format: "Biçim ve Dönüştürme", + video: "Video", + audio: "Audio", + documents: "PDF & Documents", + data: "Data Files", ai: "AI Araçları", }, modalities: { @@ -278,6 +282,74 @@ export const tr: TranslationKeys = { description: "Görüntüleri HTML, CSS ve daha fazlasına gömmek için Base64 dizelerine dönüştürün", }, + "convert-video": { + name: "Convert Video", + description: "Convert videos between MP4, MOV, and WebM", + }, + "compress-video": { + name: "Compress Video", + description: "Shrink video file size with quality control", + }, + "trim-video": { + name: "Trim Video", + description: "Cut a clip out of a video", + }, + "mute-video": { + name: "Mute Video", + description: "Remove the audio track from a video", + }, + "video-to-gif": { + name: "Video to GIF", + description: "Turn a video clip into an animated GIF", + }, + "convert-audio": { + name: "Convert Audio", + description: "Convert audio between MP3, WAV, OGG, and more", + }, + "trim-audio": { + name: "Trim Audio", + description: "Cut a section out of an audio file", + }, + "extract-audio": { + name: "Extract Audio", + description: "Pull the audio track out of a video", + }, + "merge-pdf": { + name: "Merge PDFs", + description: "Combine multiple PDFs into one", + }, + "split-pdf": { + name: "Split PDF", + description: "Extract pages or split into parts", + }, + "compress-pdf": { + name: "Compress PDF", + description: "Shrink PDF file size", + }, + "rotate-pdf": { + name: "Rotate PDF", + description: "Rotate pages in a PDF", + }, + "word-to-pdf": { + name: "Word to PDF", + description: "Convert Word documents to PDF", + }, + "csv-excel": { + name: "CSV to Excel", + description: "Convert between CSV and Excel (XLSX), both directions", + }, + "csv-json": { + name: "CSV to JSON", + description: "Convert between CSV and JSON, both directions", + }, + "json-xml": { + name: "JSON to XML", + description: "Convert between JSON and XML, both directions", + }, + "split-csv": { + name: "Split CSV", + description: "Split a CSV into smaller files by row count", + }, pipeline: { name: "Pipeline Oluşturucu", description: "Birden fazla aracı bir iş akışında zincirleyin", @@ -1242,6 +1314,133 @@ export const tr: TranslationKeys = { balanced: "Dengeli", best: "En İyi", }, + "convert-video": { + format: "Output format", + quality: "Quality", + high: "High", + balanced: "Balanced", + small: "Smallest", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "compress-video": { + quality: "Compression", + light: "Light", + balanced: "Balanced", + strong: "Strong", + resolution: "Resolution", + original: "Original", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "trim-video": { + start: "Start (seconds)", + end: "End (seconds)", + precise: "Frame-accurate (re-encodes)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "mute-video": { + submit: "Remove audio", + submitBatch: "Remove audio ({count} files)", + progressLabel: "Muting", + }, + "video-to-gif": { + fps: "Frames per second", + width: "Width (px)", + start: "Start (seconds)", + duration: "Duration (seconds)", + submit: "Create GIF", + submitBatch: "Create GIFs ({count} files)", + progressLabel: "Creating GIF", + }, + "convert-audio": { + format: "Output format", + bitrate: "Bitrate (kbps)", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "trim-audio": { + start: "Start (seconds)", + end: "End (seconds)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "extract-audio": { + format: "Output format", + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, + "merge-pdf": { + addFiles: "Add PDFs to merge", + needTwo: "At least two PDFs are required", + orderHint: "{count} PDFs will merge in this order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "split-pdf": { + mode: "Split mode", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 7-z", + everyN: "Pages per part", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, + "compress-pdf": { + preset: "Quality preset", + screen: "Screen (smallest)", + ebook: "Ebook (balanced)", + printer: "Printer (best quality)", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "rotate-pdf": { + angle: "Rotation angle", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 1-z (all pages)", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "word-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-excel": { + sheet: "Worksheet number", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-json": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "json-xml": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "split-csv": { + rowsPerFile: "Rows per file", + keepHeader: "Keep header in each file", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, }, urlImport: { title: "URL'lerden İçe Aktar", @@ -1793,7 +1992,7 @@ export const tr: TranslationKeys = { heading: "Hakkında", appName: "SnapOtter", appDescription: - "53 araçla gizlilik odaklı, kendi sunucunuzda barındırılan bir görüntü işleme paketi. Buluta veri göndermeden boyutlandırma, sıkıştırma, dönüştürme, filigran ekleme ve görüntü iş akışlarınızı otomatikleştirme.", + "70+ araçla gizlilik odaklı, kendi sunucunuzda barındırılan bir görüntü işleme paketi. Buluta veri göndermeden boyutlandırma, sıkıştırma, dönüştürme, filigran ekleme ve görüntü iş akışlarınızı otomatikleştirme.", versionLabel: "Sürüm:", linksHeading: "Bağlantılar", githubLink: "GitHub Deposu", @@ -1840,7 +2039,7 @@ export const tr: TranslationKeys = { "Limit yok. Gizli kota yok.", "Tamamen çevrimdışı çalışır.", "Sınırsız toplu işleme.", - "53 görüntü aracı.", + "70+ görüntü aracı.", "16 AI modeli. Sizin donanımınız.", "Yıldırım hızında. Sharp ile geliştirildi.", "Hava boşluklu ağlar için hazır.", diff --git a/packages/shared/src/i18n/uk.ts b/packages/shared/src/i18n/uk.ts index 387791bb..cd78f43e 100644 --- a/packages/shared/src/i18n/uk.ts +++ b/packages/shared/src/i18n/uk.ts @@ -47,6 +47,10 @@ export const uk: TranslationKeys = { utilities: "Утиліти", layout: "Компонування та композиція", format: "Формат і конвертація", + video: "Video", + audio: "Audio", + documents: "PDF & Documents", + data: "Data Files", ai: "AI-інструменти", }, modalities: { @@ -277,6 +281,74 @@ export const uk: TranslationKeys = { name: "Зображення у Base64", description: "Конвертація зображень у рядки Base64 для вбудовування у HTML, CSS та інше", }, + "convert-video": { + name: "Convert Video", + description: "Convert videos between MP4, MOV, and WebM", + }, + "compress-video": { + name: "Compress Video", + description: "Shrink video file size with quality control", + }, + "trim-video": { + name: "Trim Video", + description: "Cut a clip out of a video", + }, + "mute-video": { + name: "Mute Video", + description: "Remove the audio track from a video", + }, + "video-to-gif": { + name: "Video to GIF", + description: "Turn a video clip into an animated GIF", + }, + "convert-audio": { + name: "Convert Audio", + description: "Convert audio between MP3, WAV, OGG, and more", + }, + "trim-audio": { + name: "Trim Audio", + description: "Cut a section out of an audio file", + }, + "extract-audio": { + name: "Extract Audio", + description: "Pull the audio track out of a video", + }, + "merge-pdf": { + name: "Merge PDFs", + description: "Combine multiple PDFs into one", + }, + "split-pdf": { + name: "Split PDF", + description: "Extract pages or split into parts", + }, + "compress-pdf": { + name: "Compress PDF", + description: "Shrink PDF file size", + }, + "rotate-pdf": { + name: "Rotate PDF", + description: "Rotate pages in a PDF", + }, + "word-to-pdf": { + name: "Word to PDF", + description: "Convert Word documents to PDF", + }, + "csv-excel": { + name: "CSV to Excel", + description: "Convert between CSV and Excel (XLSX), both directions", + }, + "csv-json": { + name: "CSV to JSON", + description: "Convert between CSV and JSON, both directions", + }, + "json-xml": { + name: "JSON to XML", + description: "Convert between JSON and XML, both directions", + }, + "split-csv": { + name: "Split CSV", + description: "Split a CSV into smaller files by row count", + }, pipeline: { name: "Конструктор Pipeline", description: "Об'єднання кількох інструментів у робочий процес", @@ -1240,6 +1312,133 @@ export const uk: TranslationKeys = { balanced: "Збалансована", best: "Найкраща", }, + "convert-video": { + format: "Output format", + quality: "Quality", + high: "High", + balanced: "Balanced", + small: "Smallest", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "compress-video": { + quality: "Compression", + light: "Light", + balanced: "Balanced", + strong: "Strong", + resolution: "Resolution", + original: "Original", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "trim-video": { + start: "Start (seconds)", + end: "End (seconds)", + precise: "Frame-accurate (re-encodes)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "mute-video": { + submit: "Remove audio", + submitBatch: "Remove audio ({count} files)", + progressLabel: "Muting", + }, + "video-to-gif": { + fps: "Frames per second", + width: "Width (px)", + start: "Start (seconds)", + duration: "Duration (seconds)", + submit: "Create GIF", + submitBatch: "Create GIFs ({count} files)", + progressLabel: "Creating GIF", + }, + "convert-audio": { + format: "Output format", + bitrate: "Bitrate (kbps)", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "trim-audio": { + start: "Start (seconds)", + end: "End (seconds)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "extract-audio": { + format: "Output format", + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, + "merge-pdf": { + addFiles: "Add PDFs to merge", + needTwo: "At least two PDFs are required", + orderHint: "{count} PDFs will merge in this order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "split-pdf": { + mode: "Split mode", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 7-z", + everyN: "Pages per part", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, + "compress-pdf": { + preset: "Quality preset", + screen: "Screen (smallest)", + ebook: "Ebook (balanced)", + printer: "Printer (best quality)", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "rotate-pdf": { + angle: "Rotation angle", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 1-z (all pages)", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "word-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-excel": { + sheet: "Worksheet number", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-json": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "json-xml": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "split-csv": { + rowsPerFile: "Rows per file", + keepHeader: "Keep header in each file", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, }, urlImport: { title: "Імпорт за URL", @@ -1789,7 +1988,7 @@ export const uk: TranslationKeys = { heading: "Про програму", appName: "SnapOtter", appDescription: - "Самостійно розміщуваний набір інструментів для обробки зображень з пріоритетом конфіденційності. 53 інструменти. Зміна розміру, стиснення, конвертація, водяні знаки та автоматизація робочих процесів без надсилання даних у хмару.", + "Самостійно розміщуваний набір інструментів для обробки зображень з пріоритетом конфіденційності. 70+ інструменти. Зміна розміру, стиснення, конвертація, водяні знаки та автоматизація робочих процесів без надсилання даних у хмару.", versionLabel: "Версія:", linksHeading: "Посилання", githubLink: "Репозиторій GitHub", @@ -1836,7 +2035,7 @@ export const uk: TranslationKeys = { "Без обмежень. Без прихованих лімітів.", "Повністю працює офлайн.", "Пакетна обробка без обмежень.", - "53 інструменти для зображень.", + "70+ інструменти для зображень.", "16 AI-моделей. На Вашому обладнанні.", "Блискавична швидкість. На базі Sharp.", "Готовий до ізольованих мереж.", diff --git a/packages/shared/src/i18n/vi.ts b/packages/shared/src/i18n/vi.ts index 5703102e..8b80f07d 100644 --- a/packages/shared/src/i18n/vi.ts +++ b/packages/shared/src/i18n/vi.ts @@ -47,6 +47,10 @@ export const vi: TranslationKeys = { utilities: "Tiện ích", layout: "Bố cục & Kết hợp", format: "Định dạng & Chuyển đổi", + video: "Video", + audio: "Audio", + documents: "PDF & Documents", + data: "Data Files", ai: "Công cụ AI", }, modalities: { @@ -278,6 +282,74 @@ export const vi: TranslationKeys = { description: "Chuyển đổi hình ảnh sang chuỗi Base64 để nhúng trong HTML, CSS và nhiều hơn nữa", }, + "convert-video": { + name: "Convert Video", + description: "Convert videos between MP4, MOV, and WebM", + }, + "compress-video": { + name: "Compress Video", + description: "Shrink video file size with quality control", + }, + "trim-video": { + name: "Trim Video", + description: "Cut a clip out of a video", + }, + "mute-video": { + name: "Mute Video", + description: "Remove the audio track from a video", + }, + "video-to-gif": { + name: "Video to GIF", + description: "Turn a video clip into an animated GIF", + }, + "convert-audio": { + name: "Convert Audio", + description: "Convert audio between MP3, WAV, OGG, and more", + }, + "trim-audio": { + name: "Trim Audio", + description: "Cut a section out of an audio file", + }, + "extract-audio": { + name: "Extract Audio", + description: "Pull the audio track out of a video", + }, + "merge-pdf": { + name: "Merge PDFs", + description: "Combine multiple PDFs into one", + }, + "split-pdf": { + name: "Split PDF", + description: "Extract pages or split into parts", + }, + "compress-pdf": { + name: "Compress PDF", + description: "Shrink PDF file size", + }, + "rotate-pdf": { + name: "Rotate PDF", + description: "Rotate pages in a PDF", + }, + "word-to-pdf": { + name: "Word to PDF", + description: "Convert Word documents to PDF", + }, + "csv-excel": { + name: "CSV to Excel", + description: "Convert between CSV and Excel (XLSX), both directions", + }, + "csv-json": { + name: "CSV to JSON", + description: "Convert between CSV and JSON, both directions", + }, + "json-xml": { + name: "JSON to XML", + description: "Convert between JSON and XML, both directions", + }, + "split-csv": { + name: "Split CSV", + description: "Split a CSV into smaller files by row count", + }, 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", @@ -1239,6 +1311,133 @@ export const vi: TranslationKeys = { balanced: "Cân bằng", best: "Tốt nhất", }, + "convert-video": { + format: "Output format", + quality: "Quality", + high: "High", + balanced: "Balanced", + small: "Smallest", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "compress-video": { + quality: "Compression", + light: "Light", + balanced: "Balanced", + strong: "Strong", + resolution: "Resolution", + original: "Original", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "trim-video": { + start: "Start (seconds)", + end: "End (seconds)", + precise: "Frame-accurate (re-encodes)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "mute-video": { + submit: "Remove audio", + submitBatch: "Remove audio ({count} files)", + progressLabel: "Muting", + }, + "video-to-gif": { + fps: "Frames per second", + width: "Width (px)", + start: "Start (seconds)", + duration: "Duration (seconds)", + submit: "Create GIF", + submitBatch: "Create GIFs ({count} files)", + progressLabel: "Creating GIF", + }, + "convert-audio": { + format: "Output format", + bitrate: "Bitrate (kbps)", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "trim-audio": { + start: "Start (seconds)", + end: "End (seconds)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "extract-audio": { + format: "Output format", + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, + "merge-pdf": { + addFiles: "Add PDFs to merge", + needTwo: "At least two PDFs are required", + orderHint: "{count} PDFs will merge in this order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "split-pdf": { + mode: "Split mode", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 7-z", + everyN: "Pages per part", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, + "compress-pdf": { + preset: "Quality preset", + screen: "Screen (smallest)", + ebook: "Ebook (balanced)", + printer: "Printer (best quality)", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "rotate-pdf": { + angle: "Rotation angle", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 1-z (all pages)", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "word-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-excel": { + sheet: "Worksheet number", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-json": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "json-xml": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "split-csv": { + rowsPerFile: "Rows per file", + keepHeader: "Keep header in each file", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, }, urlImport: { title: "Nhập từ URL", @@ -1785,7 +1984,7 @@ export const vi: TranslationKeys = { heading: "Giới thiệu", appName: "SnapOtter", appDescription: - "Bộ công cụ xử lý ảnh tự lưu trữ, ưu tiên quyền riêng tư với 53 công cụ. Thay đổi kích thước, nén, chuyển đổi, thêm hình mờ và tự động hóa quy trình xử lý ảnh mà không cần gửi dữ liệu lên đám mây.", + "Bộ công cụ xử lý ảnh tự lưu trữ, ưu tiên quyền riêng tư với 70+ công cụ. Thay đổi kích thước, nén, chuyển đổi, thêm hình mờ và tự động hóa quy trình xử lý ảnh mà không cần gửi dữ liệu lên đám mây.", versionLabel: "Phiên bản:", linksHeading: "Liên kết", githubLink: "Kho mã nguồn GitHub", @@ -1831,7 +2030,7 @@ export const vi: TranslationKeys = { "Không giới hạn. Không ẩn giấu.", "Hoạt động hoàn toàn ngoại tuyến.", "Xử lý hàng loạt không giới hạn.", - "53 công cụ xử lý ảnh.", + "70+ công cụ xử lý ảnh.", "15 mô hình AI. Chạy trên phần cứng của bạn.", "Nhanh như chớp. Xây dựng trên Sharp.", "Sẵn sàng cho môi trường cách ly mạng.", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 56b9aa0a..74ce9b55 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -47,6 +47,10 @@ export const zhCN: TranslationKeys = { utilities: "实用工具", layout: "布局与排版", format: "格式与转换", + video: "Video", + audio: "Audio", + documents: "PDF & Documents", + data: "Data Files", ai: "AI 工具", }, modalities: { @@ -227,6 +231,74 @@ export const zhCN: TranslationKeys = { name: "图片转 Base64", description: "将图片转换为 Base64 字符串,可嵌入 HTML、CSS 等", }, + "convert-video": { + name: "Convert Video", + description: "Convert videos between MP4, MOV, and WebM", + }, + "compress-video": { + name: "Compress Video", + description: "Shrink video file size with quality control", + }, + "trim-video": { + name: "Trim Video", + description: "Cut a clip out of a video", + }, + "mute-video": { + name: "Mute Video", + description: "Remove the audio track from a video", + }, + "video-to-gif": { + name: "Video to GIF", + description: "Turn a video clip into an animated GIF", + }, + "convert-audio": { + name: "Convert Audio", + description: "Convert audio between MP3, WAV, OGG, and more", + }, + "trim-audio": { + name: "Trim Audio", + description: "Cut a section out of an audio file", + }, + "extract-audio": { + name: "Extract Audio", + description: "Pull the audio track out of a video", + }, + "merge-pdf": { + name: "Merge PDFs", + description: "Combine multiple PDFs into one", + }, + "split-pdf": { + name: "Split PDF", + description: "Extract pages or split into parts", + }, + "compress-pdf": { + name: "Compress PDF", + description: "Shrink PDF file size", + }, + "rotate-pdf": { + name: "Rotate PDF", + description: "Rotate pages in a PDF", + }, + "word-to-pdf": { + name: "Word to PDF", + description: "Convert Word documents to PDF", + }, + "csv-excel": { + name: "CSV to Excel", + description: "Convert between CSV and Excel (XLSX), both directions", + }, + "csv-json": { + name: "CSV to JSON", + description: "Convert between CSV and JSON, both directions", + }, + "json-xml": { + name: "JSON to XML", + description: "Convert between JSON and XML, both directions", + }, + "split-csv": { + name: "Split CSV", + description: "Split a CSV into smaller files by row count", + }, pipeline: { name: "Pipeline 构建器", description: "将多个工具串联为工作流" }, processing: { canceled: "Processing canceled" }, mediaPlayer: { @@ -1174,6 +1246,133 @@ export const zhCN: TranslationKeys = { balanced: "均衡", best: "极致", }, + "convert-video": { + format: "Output format", + quality: "Quality", + high: "High", + balanced: "Balanced", + small: "Smallest", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "compress-video": { + quality: "Compression", + light: "Light", + balanced: "Balanced", + strong: "Strong", + resolution: "Resolution", + original: "Original", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "trim-video": { + start: "Start (seconds)", + end: "End (seconds)", + precise: "Frame-accurate (re-encodes)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "mute-video": { + submit: "Remove audio", + submitBatch: "Remove audio ({count} files)", + progressLabel: "Muting", + }, + "video-to-gif": { + fps: "Frames per second", + width: "Width (px)", + start: "Start (seconds)", + duration: "Duration (seconds)", + submit: "Create GIF", + submitBatch: "Create GIFs ({count} files)", + progressLabel: "Creating GIF", + }, + "convert-audio": { + format: "Output format", + bitrate: "Bitrate (kbps)", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "trim-audio": { + start: "Start (seconds)", + end: "End (seconds)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "extract-audio": { + format: "Output format", + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, + "merge-pdf": { + addFiles: "Add PDFs to merge", + needTwo: "At least two PDFs are required", + orderHint: "{count} PDFs will merge in this order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "split-pdf": { + mode: "Split mode", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 7-z", + everyN: "Pages per part", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, + "compress-pdf": { + preset: "Quality preset", + screen: "Screen (smallest)", + ebook: "Ebook (balanced)", + printer: "Printer (best quality)", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "rotate-pdf": { + angle: "Rotation angle", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 1-z (all pages)", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "word-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-excel": { + sheet: "Worksheet number", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-json": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "json-xml": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "split-csv": { + rowsPerFile: "Rows per file", + keepHeader: "Keep header in each file", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, }, urlImport: { title: "从 URL 导入", @@ -1715,7 +1914,7 @@ export const zhCN: TranslationKeys = { heading: "关于", appName: "SnapOtter", appDescription: - "自托管、隐私优先的图片处理套件,包含 53 种工具。调整大小、压缩、转换、加水印,自动化您的图片工作流,无需将数据发送到云端。", + "自托管、隐私优先的图片处理套件,包含 70+ 种工具。调整大小、压缩、转换、加水印,自动化您的图片工作流,无需将数据发送到云端。", versionLabel: "版本:", linksHeading: "链接", githubLink: "GitHub 仓库", @@ -1760,7 +1959,7 @@ export const zhCN: TranslationKeys = { "无限制,无隐藏上限。", "完全离线可用。", "无限批量处理。", - "53 种图片工具。", + "70+ 种图片工具。", "15 个 AI 模型,在你自己的设备上运行。", "基于 Sharp 构建,闪电般快速。", "支持内网隔离部署。", diff --git a/packages/shared/src/i18n/zh-TW.ts b/packages/shared/src/i18n/zh-TW.ts index 98d06173..444a0028 100644 --- a/packages/shared/src/i18n/zh-TW.ts +++ b/packages/shared/src/i18n/zh-TW.ts @@ -47,6 +47,10 @@ export const zhTW: TranslationKeys = { utilities: "實用工具", layout: "版面與構圖", format: "格式與轉換", + video: "Video", + audio: "Audio", + documents: "PDF & Documents", + data: "Data Files", ai: "AI工具", }, modalities: { @@ -226,6 +230,74 @@ export const zhTW: TranslationKeys = { name: "影像轉Base64", description: "將影像轉換為Base64字串,嵌入HTML、CSS等", }, + "convert-video": { + name: "Convert Video", + description: "Convert videos between MP4, MOV, and WebM", + }, + "compress-video": { + name: "Compress Video", + description: "Shrink video file size with quality control", + }, + "trim-video": { + name: "Trim Video", + description: "Cut a clip out of a video", + }, + "mute-video": { + name: "Mute Video", + description: "Remove the audio track from a video", + }, + "video-to-gif": { + name: "Video to GIF", + description: "Turn a video clip into an animated GIF", + }, + "convert-audio": { + name: "Convert Audio", + description: "Convert audio between MP3, WAV, OGG, and more", + }, + "trim-audio": { + name: "Trim Audio", + description: "Cut a section out of an audio file", + }, + "extract-audio": { + name: "Extract Audio", + description: "Pull the audio track out of a video", + }, + "merge-pdf": { + name: "Merge PDFs", + description: "Combine multiple PDFs into one", + }, + "split-pdf": { + name: "Split PDF", + description: "Extract pages or split into parts", + }, + "compress-pdf": { + name: "Compress PDF", + description: "Shrink PDF file size", + }, + "rotate-pdf": { + name: "Rotate PDF", + description: "Rotate pages in a PDF", + }, + "word-to-pdf": { + name: "Word to PDF", + description: "Convert Word documents to PDF", + }, + "csv-excel": { + name: "CSV to Excel", + description: "Convert between CSV and Excel (XLSX), both directions", + }, + "csv-json": { + name: "CSV to JSON", + description: "Convert between CSV and JSON, both directions", + }, + "json-xml": { + name: "JSON to XML", + description: "Convert between JSON and XML, both directions", + }, + "split-csv": { + name: "Split CSV", + description: "Split a CSV into smaller files by row count", + }, pipeline: { name: "Pipeline建構器", description: "將多個工具串聯為工作流程" }, processing: { canceled: "Processing canceled" }, mediaPlayer: { @@ -1172,6 +1244,133 @@ export const zhTW: TranslationKeys = { balanced: "均衡", best: "最佳", }, + "convert-video": { + format: "Output format", + quality: "Quality", + high: "High", + balanced: "Balanced", + small: "Smallest", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "compress-video": { + quality: "Compression", + light: "Light", + balanced: "Balanced", + strong: "Strong", + resolution: "Resolution", + original: "Original", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "trim-video": { + start: "Start (seconds)", + end: "End (seconds)", + precise: "Frame-accurate (re-encodes)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "mute-video": { + submit: "Remove audio", + submitBatch: "Remove audio ({count} files)", + progressLabel: "Muting", + }, + "video-to-gif": { + fps: "Frames per second", + width: "Width (px)", + start: "Start (seconds)", + duration: "Duration (seconds)", + submit: "Create GIF", + submitBatch: "Create GIFs ({count} files)", + progressLabel: "Creating GIF", + }, + "convert-audio": { + format: "Output format", + bitrate: "Bitrate (kbps)", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "trim-audio": { + start: "Start (seconds)", + end: "End (seconds)", + submit: "Trim", + submitBatch: "Trim ({count} files)", + progressLabel: "Trimming", + }, + "extract-audio": { + format: "Output format", + submit: "Extract", + submitBatch: "Extract ({count} files)", + progressLabel: "Extracting", + }, + "merge-pdf": { + addFiles: "Add PDFs to merge", + needTwo: "At least two PDFs are required", + orderHint: "{count} PDFs will merge in this order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "split-pdf": { + mode: "Split mode", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 7-z", + everyN: "Pages per part", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, + "compress-pdf": { + preset: "Quality preset", + screen: "Screen (smallest)", + ebook: "Ebook (balanced)", + printer: "Printer (best quality)", + submit: "Compress", + submitBatch: "Compress ({count} files)", + progressLabel: "Compressing", + }, + "rotate-pdf": { + angle: "Rotation angle", + range: "Page range", + rangeHint: "e.g. 1-3, 5, 1-z (all pages)", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "word-to-pdf": { + submit: "Convert to PDF", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-excel": { + sheet: "Worksheet number", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "csv-json": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "json-xml": { + pretty: "Pretty print", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "split-csv": { + rowsPerFile: "Rows per file", + keepHeader: "Keep header in each file", + submit: "Split", + submitBatch: "Split ({count} files)", + progressLabel: "Splitting", + }, }, urlImport: { title: "從URL匯入", @@ -1713,7 +1912,7 @@ export const zhTW: TranslationKeys = { heading: "關於", appName: "SnapOtter", appDescription: - "自架式、隱私優先的影像處理套件,內含53個工具。調整大小、壓縮、轉檔、加入浮水印,自動化影像工作流程,無需將資料傳送到雲端。", + "自架式、隱私優先的影像處理套件,內含70+個工具。調整大小、壓縮、轉檔、加入浮水印,自動化影像工作流程,無需將資料傳送到雲端。", versionLabel: "版本:", linksHeading: "連結", githubLink: "GitHub儲存庫", @@ -1758,7 +1957,7 @@ export const zhTW: TranslationKeys = { "無限制,無隱藏上限。", "完全離線可用。", "無限批次處理。", - "53個影像工具。", + "70+個影像工具。", "15個AI模型,在你的硬體上執行。", "極速處理,基於Sharp打造。", "支援內網部署。", diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 37021c82..a70b465a 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -24,7 +24,11 @@ export type ToolCategory = | "watermark" | "utilities" | "layout" - | "format"; + | "format" + | "video" + | "audio" + | "documents" + | "data"; export interface CategoryInfo { id: ToolCategory; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c6b83b6..ee9b3bdf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,9 +176,15 @@ importers: drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(gel@2.2.0)(pg@8.21.0) + exceljs: + specifier: ^4.4.0 + version: 4.4.0 exif-reader: specifier: ^2.0.3 version: 2.0.3 + fast-xml-parser: + specifier: ^5.8.0 + version: 5.8.0 fastify: specifier: ^5.8.5 version: 5.8.5 @@ -203,6 +209,9 @@ importers: p-queue: specifier: ^9.3.0 version: 9.3.0 + papaparse: + specifier: ^5.5.3 + version: 5.5.3 pdfkit: specifier: ^0.18.0 version: 0.18.0 @@ -258,6 +267,9 @@ importers: '@types/opentype.js': specifier: ^1.3.10 version: 1.3.10 + '@types/papaparse': + specifier: ^5.5.2 + version: 5.5.2 '@types/pdfkit': specifier: ^0.17.6 version: 0.17.6 @@ -1647,6 +1659,12 @@ packages: peerDependencies: vitest: ^4.1.0 + '@fast-csv/format@4.3.5': + resolution: {integrity: sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==} + + '@fast-csv/parse@4.3.6': + resolution: {integrity: sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==} + '@fastify/accept-negotiator@2.0.1': resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} @@ -3329,6 +3347,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@14.18.63': + resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} + '@types/node@16.9.1': resolution: {integrity: sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==} @@ -3347,6 +3368,9 @@ packages: '@types/opentype.js@1.3.10': resolution: {integrity: sha512-F67EFyk6j02okHz5JCgata3ZRAcZi9GLnzmkHw/rzJq3OCc8/ZVdoKrxMTYjcQP6IYHGBz2cav1cpzkOkPiPCQ==} + '@types/papaparse@5.5.2': + resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==} + '@types/pdfkit@0.17.6': resolution: {integrity: sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==} @@ -3653,10 +3677,22 @@ packages: any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + archiver-utils@2.1.0: + resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} + engines: {node: '>= 6'} + + archiver-utils@3.0.4: + resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==} + engines: {node: '>= 10'} + archiver-utils@5.0.2: resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} engines: {node: '>= 14'} + archiver@5.3.2: + resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} + engines: {node: '>= 10'} + archiver@7.0.1: resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} engines: {node: '>= 14'} @@ -3783,6 +3819,13 @@ packages: bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + + binary@0.3.0: + resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==} + bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -3795,6 +3838,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bluebird@3.4.7: + resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + bmp-js@0.1.0: resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==} @@ -3823,6 +3869,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-crc32@1.0.0: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} engines: {node: '>=8.0.0'} @@ -3834,6 +3883,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer-indexof-polyfill@1.0.2: + resolution: {integrity: sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==} + engines: {node: '>=0.10'} + buffer@5.6.0: resolution: {integrity: sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==} @@ -3843,6 +3896,10 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + buffers@0.1.1: + resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==} + engines: {node: '>=0.2.0'} + buildcheck@0.0.7: resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} engines: {node: '>=10.0.0'} @@ -3893,6 +3950,9 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chainsaw@0.1.0: + resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==} + chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -4022,6 +4082,10 @@ packages: compare-func@2.0.0: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + compress-commons@4.1.2: + resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} + engines: {node: '>= 10'} + compress-commons@6.0.2: resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} engines: {node: '>= 14'} @@ -4103,6 +4167,10 @@ packages: engines: {node: '>=0.8'} hasBin: true + crc32-stream@4.0.3: + resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} + engines: {node: '>= 10'} + crc32-stream@6.0.0: resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} engines: {node: '>= 14'} @@ -4136,6 +4204,9 @@ packages: date-fns@4.4.0: resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -4466,6 +4537,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + exceljs@4.4.0: + resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} + engines: {node: '>=8.3.0'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -4506,6 +4581,10 @@ packages: fast-content-type-parse@3.0.0: resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} + fast-csv@4.3.6: + resolution: {integrity: sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==} + engines: {node: '>=10.0.0'} + fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -4540,6 +4619,10 @@ packages: resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} hasBin: true + fast-xml-parser@5.8.0: + resolution: {integrity: sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==} + hasBin: true + fastify-plugin@4.5.1: resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} @@ -4667,6 +4750,11 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + fstream@1.0.12: + resolution: {integrity: sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==} + engines: {node: '>=0.6'} + deprecated: This package is no longer supported. + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -5183,6 +5271,9 @@ packages: engines: {node: '>=20.17'} hasBin: true + listenercount@1.0.1: + resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==} + listr2@9.0.5: resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} engines: {node: '>=20.0.0'} @@ -5214,21 +5305,49 @@ packages: lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + lodash.difference@4.5.0: + resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} + lodash.escaperegexp@4.1.2: resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + lodash.flatten@4.4.0: + resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + lodash.groupby@4.6.0: resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} lodash.isarguments@3.1.0: resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + lodash.isfunction@3.0.9: + resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} + + lodash.isnil@4.0.0: + resolution: {integrity: sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==} + lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} lodash.isstring@4.0.1: resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.isundefined@3.0.1: + resolution: {integrity: sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==} + + lodash.union@4.6.0: + resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + lodash.uniqby@4.7.0: resolution: {integrity: sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==} @@ -5812,6 +5931,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + papaparse@5.5.3: + resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -6340,6 +6462,11 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + rolldown@1.0.3: resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6369,6 +6496,10 @@ packages: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} + saxes@5.0.1: + resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==} + engines: {node: '>=10'} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -6853,6 +6984,9 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} + traverse@0.3.9: + resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==} + traverse@0.6.8: resolution: {integrity: sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==} engines: {node: '>= 0.4'} @@ -7004,6 +7138,9 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + unzipper@0.10.14: + resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -7026,6 +7163,11 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -7284,6 +7426,10 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + zip-stream@4.1.1: + resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} + engines: {node: '>= 10'} + zip-stream@6.0.1: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} @@ -8416,6 +8562,25 @@ snapshots: fast-check: 4.8.0 vitest: 3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3) + '@fast-csv/format@4.3.5': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.isboolean: 3.0.3 + lodash.isequal: 4.5.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + + '@fast-csv/parse@4.3.6': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.groupby: 4.6.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + lodash.isundefined: 3.0.1 + lodash.uniq: 4.5.0 + '@fastify/accept-negotiator@2.0.1': {} '@fastify/ajv-compiler@4.0.5': @@ -10281,6 +10446,8 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node@14.18.63': {} + '@types/node@16.9.1': {} '@types/node@18.19.130': @@ -10300,6 +10467,10 @@ snapshots: '@types/opentype.js@1.3.10': {} + '@types/papaparse@5.5.2': + dependencies: + '@types/node': 22.19.19 + '@types/pdfkit@0.17.6': dependencies: '@types/node': 22.19.19 @@ -10645,6 +10816,32 @@ snapshots: any-promise@1.3.0: {} + archiver-utils@2.1.0: + dependencies: + glob: 13.0.6 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 2.3.8 + + archiver-utils@3.0.4: + dependencies: + glob: 13.0.6 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + archiver-utils@5.0.2: dependencies: glob: 10.5.0 @@ -10655,6 +10852,16 @@ snapshots: normalize-path: 3.0.0 readable-stream: 4.7.0 + archiver@5.3.2: + dependencies: + archiver-utils: 2.1.0 + async: 3.2.6 + buffer-crc32: 0.2.13 + readable-stream: 3.6.2 + readdir-glob: 1.1.3 + tar-stream: 2.2.0 + zip-stream: 4.1.1 + archiver@7.0.1: dependencies: archiver-utils: 5.0.2 @@ -10770,6 +10977,13 @@ snapshots: dependencies: require-from-string: 2.0.2 + big-integer@1.6.52: {} + + binary@0.3.0: + dependencies: + buffers: 0.1.1 + chainsaw: 0.1.0 + bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 @@ -10784,6 +10998,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + bluebird@3.4.7: {} + bmp-js@0.1.0: {} bottleneck@2.19.5: {} @@ -10814,12 +11030,16 @@ snapshots: node-releases: 2.0.36 update-browserslist-db: 1.2.3(browserslist@4.28.1) + buffer-crc32@0.2.13: {} + buffer-crc32@1.0.0: {} buffer-equal@0.0.1: {} buffer-from@1.1.2: {} + buffer-indexof-polyfill@1.0.2: {} + buffer@5.6.0: dependencies: base64-js: 1.5.1 @@ -10835,6 +11055,8 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buffers@0.1.1: {} + buildcheck@0.0.7: optional: true @@ -10885,6 +11107,10 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chainsaw@0.1.0: + dependencies: + traverse: 0.3.9 + chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -11006,6 +11232,13 @@ snapshots: array-ify: 1.0.0 dot-prop: 5.3.0 + compress-commons@4.1.2: + dependencies: + buffer-crc32: 0.2.13 + crc32-stream: 4.0.3 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + compress-commons@6.0.2: dependencies: crc-32: 1.2.2 @@ -11082,6 +11315,11 @@ snapshots: crc-32@1.2.2: {} + crc32-stream@4.0.3: + dependencies: + crc-32: 1.2.2 + readable-stream: 3.6.2 + crc32-stream@6.0.0: dependencies: crc-32: 1.2.2 @@ -11119,6 +11357,8 @@ snapshots: date-fns@4.4.0: {} + dayjs@1.11.21: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -11398,6 +11638,18 @@ snapshots: events@3.3.0: {} + exceljs@4.4.0: + dependencies: + archiver: 5.3.2 + dayjs: 1.11.21 + fast-csv: 4.3.6 + jszip: 3.10.1 + readable-stream: 3.6.2 + saxes: 5.0.1 + tmp: 0.2.7 + unzipper: 0.10.14 + uuid: 8.3.2 + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -11457,6 +11709,11 @@ snapshots: fast-content-type-parse@3.0.0: {} + fast-csv@4.3.6: + dependencies: + '@fast-csv/format': 4.3.5 + '@fast-csv/parse': 4.3.6 + fast-decode-uri-component@1.0.1: {} fast-deep-equal@3.1.3: {} @@ -11500,6 +11757,14 @@ snapshots: path-expression-matcher: 1.5.0 strnum: 2.3.0 + fast-xml-parser@5.8.0: + dependencies: + '@nodable/entities': 2.1.1 + fast-xml-builder: 1.2.0 + path-expression-matcher: 1.5.0 + strnum: 2.3.0 + xml-naming: 0.1.0 + fastify-plugin@4.5.1: {} fastify-plugin@5.1.0: {} @@ -11630,6 +11895,13 @@ snapshots: fsevents@2.3.3: optional: true + fstream@1.0.12: + dependencies: + graceful-fs: 4.2.11 + inherits: 2.0.4 + mkdirp: 0.5.6 + rimraf: 2.7.1 + function-bind@1.1.2: {} function-timeout@1.0.2: {} @@ -12162,6 +12434,8 @@ snapshots: tinyexec: 1.0.4 yaml: 2.8.3 + listenercount@1.0.1: {} + listr2@9.0.5: dependencies: cli-truncate: 5.2.0 @@ -12208,16 +12482,34 @@ snapshots: lodash.defaults@4.2.0: {} + lodash.difference@4.5.0: {} + lodash.escaperegexp@4.1.2: {} + lodash.flatten@4.4.0: {} + lodash.groupby@4.6.0: {} lodash.isarguments@3.1.0: {} + lodash.isboolean@3.0.3: {} + + lodash.isequal@4.5.0: {} + + lodash.isfunction@3.0.9: {} + + lodash.isnil@4.0.0: {} + lodash.isplainobject@4.0.6: {} lodash.isstring@4.0.1: {} + lodash.isundefined@3.0.1: {} + + lodash.union@4.6.0: {} + + lodash.uniq@4.5.0: {} + lodash.uniqby@4.7.0: {} lodash@4.18.1: {} @@ -12800,6 +13092,8 @@ snapshots: pako@1.0.11: {} + papaparse@5.5.3: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -13359,6 +13653,10 @@ snapshots: rfdc@1.4.1: {} + rimraf@2.7.1: + dependencies: + glob: 13.0.6 + rolldown@1.0.3: dependencies: '@oxc-project/types': 0.133.0 @@ -13398,6 +13696,10 @@ snapshots: sax@1.6.0: {} + saxes@5.0.1: + dependencies: + xmlchars: 2.2.0 + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -13975,6 +14277,8 @@ snapshots: dependencies: punycode: 2.3.1 + traverse@0.3.9: {} + traverse@0.6.8: {} tree-kill@1.2.2: {} @@ -14117,6 +14421,19 @@ snapshots: universalify@2.0.1: {} + unzipper@0.10.14: + dependencies: + big-integer: 1.6.52 + binary: 0.3.0 + bluebird: 3.4.7 + buffer-indexof-polyfill: 1.0.2 + duplexer2: 0.1.4 + fstream: 1.0.12 + graceful-fs: 4.2.11 + listenercount: 1.0.1 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -14136,6 +14453,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@8.3.2: {} + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 @@ -14471,6 +14790,12 @@ snapshots: yoctocolors@2.1.2: {} + zip-stream@4.1.1: + dependencies: + archiver-utils: 3.0.4 + compress-commons: 4.1.2 + readable-stream: 3.6.2 + zip-stream@6.0.1: dependencies: archiver-utils: 5.0.2 diff --git a/scripts/generate-test-fixtures.mjs b/scripts/generate-test-fixtures.mjs index a4a58015..c097a6e5 100644 --- a/scripts/generate-test-fixtures.mjs +++ b/scripts/generate-test-fixtures.mjs @@ -29,9 +29,11 @@ function run(args) { } } -// 1s 64x64 silent mp4 (h264 baseline, tiny) +// 1s 64x64 mp4 with video + audio (h264 + aac; needed by extract-audio + mute-video) run(["-f", "lavfi", "-i", "testsrc=duration=1:size=64x64:rate=8", + "-f", "lavfi", "-i", "sine=frequency=440:duration=1", "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", + "-c:a", "aac", "-b:a", "32k", "-shortest", join(mediaDir, "tiny.mp4")]); // 1s sine mp3 run(["-f", "lavfi", "-i", "sine=frequency=440:duration=1", "-c:a", "libmp3lame", "-b:a", "32k", diff --git a/tests/e2e/document-mode.spec.ts b/tests/e2e/document-mode.spec.ts new file mode 100644 index 00000000..9f2a2a56 --- /dev/null +++ b/tests/e2e/document-mode.spec.ts @@ -0,0 +1,65 @@ +import path from "node:path"; +import { expect, test, waitForProcessing } from "./helpers"; + +const PDF_FIXTURE = path.join(process.cwd(), "tests", "fixtures", "test-3page.pdf"); + +test.describe("Document display mode (rotate-pdf)", () => { + test("uploads a PDF, rotates it, and shows the document canvas with processed result", async ({ + loggedInPage: page, + }) => { + await page.goto("/rotate-pdf"); + + // Upload test-3page.pdf via file chooser + const fileChooserPromise = page.waitForEvent("filechooser"); + const uploadButton = page.getByRole("button", { name: /upload from computer/i }).first(); + if (await uploadButton.isVisible({ timeout: 2000 }).catch(() => false)) { + await uploadButton.click(); + } else { + await page.locator("[class*='border-dashed']").first().click(); + } + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(PDF_FIXTURE); + await page.waitForTimeout(500); + + // The document canvas should show the source PDF immediately + const canvas = page.locator("[data-testid='document-canvas']"); + await expect(canvas).toBeVisible({ timeout: 15_000 }); + + // Click submit to rotate the PDF (default: 90 degrees, all pages) + await page.getByTestId("rotate-pdf-submit").click(); + + // Wait for processing to complete + await waitForProcessing(page, 60_000); + + // After processing, the review panel appears with a Download button + await expect(page.getByText("Download").first()).toBeVisible({ timeout: 30_000 }); + + // The document canvas should still be visible (now showing the rotated PDF) + await expect(canvas).toBeVisible(); + }); + + test("document viewer shows page navigation for multi-page PDF", async ({ + loggedInPage: page, + }) => { + await page.goto("/rotate-pdf"); + + // Upload test-3page.pdf + const fileChooserPromise = page.waitForEvent("filechooser"); + const uploadButton = page.getByRole("button", { name: /upload from computer/i }).first(); + if (await uploadButton.isVisible({ timeout: 2000 }).catch(() => false)) { + await uploadButton.click(); + } else { + await page.locator("[class*='border-dashed']").first().click(); + } + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(PDF_FIXTURE); + await page.waitForTimeout(500); + + // Wait for the canvas to render the first page + const canvas = page.locator("[data-testid='document-canvas']"); + await expect(canvas).toBeVisible({ timeout: 15_000 }); + + // Page navigation should appear for a 3-page PDF ("1 / 3") + await expect(page.getByText("1 / 3")).toBeVisible({ timeout: 10_000 }); + }); +}); diff --git a/tests/e2e/media-player-mode.spec.ts b/tests/e2e/media-player-mode.spec.ts new file mode 100644 index 00000000..290b1299 --- /dev/null +++ b/tests/e2e/media-player-mode.spec.ts @@ -0,0 +1,63 @@ +import path from "node:path"; +import { expect, test, waitForProcessing } from "./helpers"; + +const MP4_FIXTURE = path.join(process.cwd(), "tests", "fixtures", "media", "tiny.mp4"); + +test.describe("Media-player display mode (mute-video)", () => { + test("uploads a video, mutes it, and shows the media player with processed result", async ({ + loggedInPage: page, + }) => { + await page.goto("/mute-video"); + + // Upload tiny.mp4 via the file chooser (dropzone click) + const fileChooserPromise = page.waitForEvent("filechooser"); + const uploadButton = page.getByRole("button", { name: /upload from computer/i }).first(); + if (await uploadButton.isVisible({ timeout: 2000 }).catch(() => false)) { + await uploadButton.click(); + } else { + await page.locator("[class*='border-dashed']").first().click(); + } + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(MP4_FIXTURE); + await page.waitForTimeout(500); + + // The media-player view should show the source video immediately + const videoEl = page.locator("[data-testid='media-player-video']"); + await expect(videoEl).toBeVisible({ timeout: 15_000 }); + + // Click submit to mute the video + await page.getByTestId("mute-video-submit").click(); + + // Wait for processing to complete (spinner disappears) + await waitForProcessing(page, 60_000); + + // After processing, the review panel appears with a Download button + await expect(page.getByText("Download").first()).toBeVisible({ timeout: 30_000 }); + + // The media player should still be visible and now show the processed result + await expect(videoEl).toBeVisible(); + const src = await videoEl.getAttribute("src"); + expect(src).toBeTruthy(); + expect(src!.length).toBeGreaterThan(0); + }); + + test("media player video element has controls attribute", async ({ loggedInPage: page }) => { + await page.goto("/mute-video"); + + // Upload tiny.mp4 + const fileChooserPromise = page.waitForEvent("filechooser"); + const uploadButton = page.getByRole("button", { name: /upload from computer/i }).first(); + if (await uploadButton.isVisible({ timeout: 2000 }).catch(() => false)) { + await uploadButton.click(); + } else { + await page.locator("[class*='border-dashed']").first().click(); + } + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(MP4_FIXTURE); + await page.waitForTimeout(500); + + const videoEl = page.locator("[data-testid='media-player-video']"); + await expect(videoEl).toBeVisible({ timeout: 15_000 }); + await expect(videoEl).toHaveAttribute("controls", /.*/); + }); +}); diff --git a/tests/fixtures/data/tiny.csv b/tests/fixtures/data/tiny.csv new file mode 100644 index 00000000..d7a44010 --- /dev/null +++ b/tests/fixtures/data/tiny.csv @@ -0,0 +1,4 @@ +name,age +Ada,36 +Grace,45 +Alan,41 diff --git a/tests/fixtures/data/tiny.json b/tests/fixtures/data/tiny.json new file mode 100644 index 00000000..2766c20b --- /dev/null +++ b/tests/fixtures/data/tiny.json @@ -0,0 +1 @@ +[{ "name": "Ada", "age": 36 }, { "name": "Grace", "age": 45 }] diff --git a/tests/fixtures/data/tiny.xml b/tests/fixtures/data/tiny.xml new file mode 100644 index 00000000..06a3322d --- /dev/null +++ b/tests/fixtures/data/tiny.xml @@ -0,0 +1 @@ +AdaGrace diff --git a/tests/fixtures/media/tiny.mp4 b/tests/fixtures/media/tiny.mp4 index db654b599919f9f77d7c64a59b9b224eab26d357..92361017df49dc9f772f96ad86a83dd41cd48614 100644 GIT binary patch delta 5102 zcmY*c2|QHo_rHTgls#LPCi_m(ONmBgD?24o$i8JpvJP*GY_H59`@UrvYeW+~u4v5{(zv(p4}LsZn~;-_p_Zh#Gl?(d~1! z(=#OgbM<{qc0nUe_QSt+q*zX**fSOuRu*<67EM-UBog9GR?3AP#UlQ~f9bl7Hz^Sx ziC^d=|L91)=mPGHE|2m|TM)q7>1}dH{m2qHwMhwFDX%R#PI;*q#LmT#rC;$lv-qQ@ zcuYf!<6n%7KiTBu&em&dA713nHim_ja&d8wzeQa~xe1fL9jh2ONNcdhmo1m(y((te9N1VN|p zY$}~D+`M!fT-d`dfj0xKYjKy<)YyLP{1|(si!BnNFG%H5wJt8O8q#m5N~KS7`a}tS z-BzG>?!ChpD8A*>&FnUbpjC)O2GNDupKZEHl|Nu8-=^@=w0?sKS0FT^p0c&ZrKrcDsL zv<;5#|J)ksF2At8NysF5kl2K$2(o};)i;v=zIV4&RkwJx!%7mjnG4|te#lI*Kx0g! zZurO+f<_@CQ9dJ1qu^cuI7D2bIMg>btt+o@lGHeQF3iuH(!B08os~x$&3+?T6T7Wy zYpZ)2YrV@7sU3VaUh=&<=R35Sc!N%?3OU1u>29g6Ir@kOi(piUOH{}R)5vC?53^)F z5f%VqGxwS|ONQ%o1F(4Y@<*W9^35Ip#e+REISH=L@s|K0ecF0^P$#Znnmbz zaD91SZAtbiwr#|dFugvfViBId&Mjh1qpz=6GIpt&7ZF~ds@VOu>N01m@)yOIV{J+O z>4im$`0+jciZh0$H#|tIHn&0`$3oV}Mrh=)I9*Y>yadLoWwtfT=*f~dM}JT*Jh(Tk z^$uuh=~r0JaXb_2A|SrqY$J;&$GTJ{$uoa%5JF%Fs%pI$5r*5Wk<9YSJg0AddD9%^ zLUOEq#V{HIIUJI1n2EB3a^P=12A{r-eYFiTx@5|-k<@NpNg_%lCz*NiCffLUo_=D< z&^I4u#BOv2$zsMRj6{lir$v2U6iadpWRDYzDUS4C`AnWV!(nqQg~(N50Ox5$qJnws zf#)IHPdqhS-hBx6$i{M7{!=#r8jSLNRwmrz7M$~&S>^eT&r;Gk?l+eK|7FU=&wG4V zMTBSve((~b(i=X@emYb8X%yW@DWS&eL0jBg)#n*isj&wt6x9n+XNKr7NDjxoF)~P! z*&6wkOIm1_ADdo$VJm)gFp{~Nl6+@K&**WF;?On4Aw{!->`y6w-J>!WUGwXW!uY1B zS78Q{JLqrj>AmaWIYUm3u7^|c!R7KWjXdW0)I!PyF!Q|sw_w+F^13R8F@yh-Co;}J z1si>h4u@m`PTKX8h{5~dlO5|d-~8n!ORM~*imn+u`c+Esp5kbbytM^?bHzJTQ%OwM z{qz~(mV>Cmc8m5$tVy834s2ihM6Fa=;Qm}}Y@hXru(^H(EiEmX<^K9f2T~JY=kO*b zl~(DvL}+E1q?Xi**lX37{*lVq!6uQtH~!`o4JQXxHhPn$nk1dag1Se)J^`IYByN=; zea4Kvjk&9fiEMH(Jj%a+v3DkB)(@#8L3V$J)~2o?w!1@QCWf^AX2nL++0c z^>{bHVjUuuNLm&kNm&WC;j`XJWQBB^Uo^POd9wRoJskl+W>8ZV*GtBppZI#PycD%_ zwUEPXB=9MyX1wm}EE?LYiQuN%I@U_=I7V07? zp30)*!UO8BgF~R>!0?rr4Z)0|{E<-isvN^(gh8x2OH+-ri@f8po??a|o`L4q=dPVf zz1#xPN+4zoz%Fi(e7%q<8@nad^3=lt^sb<;L=Al zX|`WnjvOWtNtZ~y;}d-MMg+NQ2T0H6c0hCeoue6cAN->2WcB#J4B*z{g_2K{#LtpU zcCO}OLlybW`B36odxx7%L==1^;O9UNN~T}QNROk{9;JMztGfojkN{}w^rug@}D%P1M80}j2C%H&hib4q( z@c?B=K%x4dD$0z53#9#Eg5{K+`R(m3Lful_+Q!cNq9W1EVKy_{>- zGsHv&d z^F^83`+78HSGNP3ZN$HKo9TX^K6m?_BrjFap4EL-Ep%0cu8}k@+S;fEpup3K4b#kK zaSBsGo_IX&3wqj+w~{(JJ2cinzMXyNxs8o3-kvXF0J6YRH@+mPU6EOAUM`*13Se{%` zn%=K*biLG@KN6u;hO_83N zsOjmP%m2(CpE-i68=5uAOLsFd=5-HnLiges(-2g!uhr|cpe$LFDg&p9ySukZY!l;gZaQGA z(|oIE+}IR|i+kR`CVhv)z))MKqJ{fnJnj!8qGfe|6uUSK!rC#^h@XPO=--tb~DHD(%ncxox!uR|eC zh#%Dqd1C2SHTlP%o7Se=h8 zu*Ff{-7Swd)M-KJ(EzJ78+_xsoz73Tax+;A8q;fv!wQV4$7m{s2P*qnQ>x=w7~eGyXs-fglvm$MhjC|bBeIoS9Y+Eh+X zfOY@uEG|b))#|OU_wK*^IVQ1+Q>k2o%afncX^K~&&_mS;*>5uQl5ccE(=-<^(Umdo zKH|G@V@C8b+GmD2ZC3Dlg=9u+6q^N7GBYWwu2$D{lKa{NDfR!JPf9>$?HHk0#|0Cq zR$Q>`ui%fEoS1;-lup$JZQENw$JgwKcj(W>B+IEwAEEmNCuE|+&0G|6SZNc?-KXGU zs|zp`BJsk8*~N>267h+m8NS29RL{h$&N&4aX82t9ku;2T9#Q!5bDa7XpIM{2&MC}T z%>7&P6|(C&tv(dfQ-`YvTCdqGSSM|`9wykV&FvHY{6{+KtoD~ki|J|Wy)MNvMev3g zRa_(|Not*4orZsjGIk#*H@9Z#g5@NHS2hH*M1%deZ&~&C%TGHNt|8Y3Q!E{o;~(+s zO?>WK9G9pl)`&v(Co8%y6Zd>6phfA(icB6sz-nvvTa8UnH&&yqSG}KxyUs6hh*fjKFrcAGI>B&Zn6O~gPZuXPz zwCXL@L}}AXP{&v*&A;njRAoWwidXf0p3vXh1yq>)K7182h(CyX&B9A2#+!tprGKzx zuXf93cWnVyRuLt1(S??ohX`R5N;mAOq`RJO0zKQH_rP0(^LA;6YrYyk=u}D{g_xDD z4uL|FH?0b#A#zBWkh!!^B<=sa-#&p^AvRO6R%NfHeV!UWF_M?VW{6U*Cd9;~D!I9R zu5@aj-f){YxxA|-@2x*Zh`pzZu5qswyh)2@6uet@N7($uufuZrMy}TBp~_{D4OD5e zIc{a~Pvpvvm`3F^dP5}x<%#-lLMyqq6b`E7G;&W>A!*`7se?wB{i@P&VwmWRnI&o` zG@j%2O2H%+p<;~VF**^Uku5l%U_wz-Tkd$Z`mNpB*ZGvO>=(_&`J>_Cb+)aot$n2c zBAlPU4ggHT!^>I#_HW;Q)B{v$7gap~ppc0P`3K*}3}Qst)R?&*LQUXU;G>kZN6Ec!J)xokaI1W#h!WgYel>(E zE}Wl&K;WJTT#%PTW9&SE!$t>nT>x;*2mqK10pO${0Pt)9fWQp^5Rw7_v0eb6z6*)t z<)u1&fqMS1Ke!?gaEHLf@$uud_?U1#e7sm1hyin{KD*mH--QT4cY7y$nDbvNy#fFg zSIpWDIMPn`Zr*>Mp*?*dKmXTEpnH4myB_v#_uw&9VeZZz4iLeNcK?4uh3pUcr*N+P zJXk7iG{)8q8WxPkp#SKGt@3d9QiN@S+kut8DQ38bt%91;J+wUmFz*ll=zo0dfv`Uh=>K~Fyn___L-&80 zG(;pr$cCT_K?}k)2$~SCLvV#)4B_@~SV6tzZyJt=1q4F~u&;0ah8ci8eBlAafM0fm z01q;N0MFJI0?dO2-GUGV0aj`XK_7w%1o&N8IjjK6#e;WY`Vj X=vxbl6qh9DGe7_%kTRIuX`BcEX9*^M diff --git a/tests/helpers/tool-default-settings.ts b/tests/helpers/tool-default-settings.ts index e753a288..fdbd1cb5 100644 --- a/tests/helpers/tool-default-settings.ts +++ b/tests/helpers/tool-default-settings.ts @@ -15,6 +15,9 @@ export const TOOL_SETTINGS_OVERRIDES: Record = { "watermark-text": { text: "Test" }, "text-overlay": { text: "Test" }, "passport-photo": { countryCode: "us" }, + "trim-video": { startS: 0, endS: 5 }, + "trim-audio": { startS: 0, endS: 5 }, + "split-pdf": { mode: "range", range: "1" }, }; export function defaultSettingsFor(toolId: string): unknown { diff --git a/tests/integration/adversarial-coverage-gaps.test.ts b/tests/integration/adversarial-coverage-gaps.test.ts index e7829204..59971360 100644 --- a/tests/integration/adversarial-coverage-gaps.test.ts +++ b/tests/integration/adversarial-coverage-gaps.test.ts @@ -908,7 +908,7 @@ describe("Multipart with extra unexpected fields", () => { // Two file parts trigger the "one image at a time" rejection. expect(res.statusCode).toBe(400); const json = JSON.parse(res.body); - expect(json.error).toMatch(/one image at a time/i); + expect(json.error).toMatch(/too many files/i); }); }); diff --git a/tests/integration/adversarial.test.ts b/tests/integration/adversarial.test.ts index a890a2ac..851b8ad9 100644 --- a/tests/integration/adversarial.test.ts +++ b/tests/integration/adversarial.test.ts @@ -296,7 +296,7 @@ describe("Duplicate file fields in multipart", () => { // Tool factory rejects multiple file parts with 400 expect(res.statusCode).toBe(400); const json = JSON.parse(res.body); - expect(json.error).toMatch(/one image at a time/i); + expect(json.error).toMatch(/too many files/i); }); }); diff --git a/tests/integration/border.test.ts b/tests/integration/border.test.ts index e006b4ca..f49fc97d 100644 --- a/tests/integration/border.test.ts +++ b/tests/integration/border.test.ts @@ -870,7 +870,7 @@ describe("Border", () => { expect(res.statusCode).toBe(400); const result = JSON.parse(res.body); - expect(result.error).toMatch(/one image/i); + expect(result.error).toMatch(/too many files/i); }); // ── Invalid settings JSON ──────────────────────────────────────── diff --git a/tests/integration/compress-pdf.test.ts b/tests/integration/compress-pdf.test.ts new file mode 100644 index 00000000..3a6ef66e --- /dev/null +++ b/tests/integration/compress-pdf.test.ts @@ -0,0 +1,45 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { gsAvailable } from "@snapotter/doc-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const PDF = readFileSync(join(__dirname, "..", "fixtures", "test-3page.pdf")); + +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.skipIf(!gsAvailable())("compress-pdf (requires gs)", () => { + it("compresses with ebook preset and returns valid PDF", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test-3page.pdf", contentType: "application/pdf", content: PDF }, + { name: "settings", content: JSON.stringify({ preset: "ebook" }) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/compress-pdf", + 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); + // Verify the output starts with %PDF- + expect(dl.rawPayload.subarray(0, 5).toString()).toBe("%PDF-"); + }, 60_000); +}); diff --git a/tests/integration/compress-video.test.ts b/tests/integration/compress-video.test.ts new file mode 100644 index 00000000..3b7eabfc --- /dev/null +++ b/tests/integration/compress-video.test.ts @@ -0,0 +1,70 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { ffmpegAvailable } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4")); + +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 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 } | 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; +} + +async function runTool(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/compress-video", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("compress-video (requires ffmpeg)", () => { + it("returns 202 and produces a compressed mp4", async () => { + const res = await runTool({ quality: "balanced" }); + 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; + expect(outName).toContain("_compressed.mp4"); + const dl = await testApp.app.inject({ + method: "GET", + url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`, + }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.length).toBeGreaterThan(100); + }, 90_000); + + it("compresses with 480p resolution", async () => { + const res = await runTool({ quality: "strong", resolution: "480p" }); + expect(res.statusCode).toBe(202); + const { jobId } = JSON.parse(res.body); + const row = await pollJob(jobId); + expect(row?.status).toBe("completed"); + }, 90_000); +}); diff --git a/tests/integration/convert-audio.test.ts b/tests/integration/convert-audio.test.ts new file mode 100644 index 00000000..47f6dafb --- /dev/null +++ b/tests/integration/convert-audio.test.ts @@ -0,0 +1,66 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { ffmpegAvailable } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const WAV = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.wav")); +const MP3 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp3")); + +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(settings: Record, file = WAV, filename = "tiny.wav") { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename, contentType: "audio/wav", content: file }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/convert-audio", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("convert-audio (requires ffmpeg)", () => { + it("converts wav to mp3 and returns 200", async () => { + const res = await runTool({ format: "mp3" }); + 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.length).toBeGreaterThan(100); + const outName = envelope.downloadUrl.split("/").pop() as string; + expect(outName.endsWith(".mp3")).toBe(true); + }, 60_000); + + it("converts mp3 to ogg and returns 200", async () => { + // Use mp3 fixture (44100 Hz) because libvorbis rejects the 8 kHz wav + const res = await runTool({ format: "ogg" }, MP3, "tiny.mp3"); + 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.length).toBeGreaterThan(100); + const outName = envelope.downloadUrl.split("/").pop() as string; + expect(outName.endsWith(".ogg")).toBe(true); + }, 60_000); +}); diff --git a/tests/integration/convert-video.test.ts b/tests/integration/convert-video.test.ts new file mode 100644 index 00000000..b08cecf0 --- /dev/null +++ b/tests/integration/convert-video.test.ts @@ -0,0 +1,91 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { ffmpegAvailable } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4")); + +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(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/convert-video", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("convert-video (requires ffmpeg)", () => { + it("returns 202 (long hint) and the job completes with a webm", async () => { + const res = await runTool({ format: "webm", quality: "small" }); + 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(outName.endsWith(".webm")).toBe(true); + expect(dl.rawPayload.length).toBeGreaterThan(100); + }, 90_000); + + it("converts to mp4 (default settings)", async () => { + const res = await runTool({ format: "mp4" }); + 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; + expect(outName.endsWith(".mp4")).toBe(true); + }, 90_000); + + it("rejects a non-video upload", async () => { + const png = readFileSync(join(__dirname, "..", "fixtures", "test-1x1.png")); + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "x.mp4", contentType: "video/mp4", content: png }, + { name: "settings", content: JSON.stringify({ format: "mp4" }) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/convert-video", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error).toMatch(/still image|Unrecognized video/); + }); +}); diff --git a/tests/integration/csv-excel.test.ts b/tests/integration/csv-excel.test.ts new file mode 100644 index 00000000..e5fa8cec --- /dev/null +++ b/tests/integration/csv-excel.test.ts @@ -0,0 +1,81 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import AdmZip from "adm-zip"; +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 XLSX_FIXTURE = 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/csv-excel", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe("csv-excel (pure JS, no skipIf)", () => { + it("converts CSV to XLSX with PK magic and reloadable content", async () => { + const res = await runTool("tiny.csv", CSV); + 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); + + // XLSX is a ZIP containing xl/worksheets/sheet1.xml. + // Parse the zip and verify the first sheet's XML has the header "name". + const zip = new AdmZip(Buffer.from(dl.rawPayload)); + const sheetEntry = zip.getEntry("xl/worksheets/sheet1.xml"); + expect(sheetEntry).toBeDefined(); + // The shared strings table stores cell values; look there + const sst = zip.getEntry("xl/sharedStrings.xml"); + expect(sst).toBeDefined(); + const sstXml = sst?.getData().toString("utf8") ?? ""; + expect(sstXml).toContain("name"); + }, 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) + const res = await runTool("tiny.xlsx", XLSX_FIXTURE, { sheet: 1 }); + 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 csvText = dl.payload; + expect(csvText).toContain("SnapOtter"); + }, 30_000); +}); diff --git a/tests/integration/csv-json.test.ts b/tests/integration/csv-json.test.ts new file mode 100644 index 00000000..efe088ba --- /dev/null +++ b/tests/integration/csv-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 CSV = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.csv")); +const JSON_FIXTURE = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.json")); + +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/csv-json", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe("csv-json (pure JS, no skipIf)", () => { + it("converts CSV to JSON with the Ada row present", async () => { + const res = await runTool("tiny.csv", CSV, { 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); + const ada = data.find((r: Record) => r.name === "Ada"); + expect(ada).toBeDefined(); + expect(ada.age).toBe("36"); + }, 30_000); + + it("converts JSON to CSV containing name,age header", async () => { + const res = await runTool("tiny.json", JSON_FIXTURE); + 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 csvText = dl.payload; + expect(csvText).toContain("name,age"); + expect(csvText).toContain("Ada"); + }, 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); + expect(res.statusCode).toBe(422); + }, 30_000); +}); diff --git a/tests/integration/doc-engine-pdf-ops.test.ts b/tests/integration/doc-engine-pdf-ops.test.ts new file mode 100644 index 00000000..577b0f0b --- /dev/null +++ b/tests/integration/doc-engine-pdf-ops.test.ts @@ -0,0 +1,78 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + gsAvailable, + gsCompressPdf, + qpdfAvailable, + qpdfMerge, + qpdfPageCount, + qpdfRotate, + qpdfSplitRanges, +} from "@snapotter/doc-engine"; +import { describe, expect, it } from "vitest"; + +const PDF = join(process.cwd(), "tests/fixtures/test-3page.pdf"); + +describe.skipIf(!qpdfAvailable())("doc-engine pdf ops (requires qpdf)", () => { + it("merges two pdfs into one with summed pages", async () => { + const dir = mkdtempSync(join(tmpdir(), "pdf-ops-")); + try { + const out = join(dir, "merged.pdf"); + await qpdfMerge([PDF, PDF], out); + expect(await qpdfPageCount(out)).toBe(6); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("splits a range into a new pdf", async () => { + const dir = mkdtempSync(join(tmpdir(), "pdf-ops-")); + try { + const out = join(dir, "part.pdf"); + await qpdfSplitRanges(PDF, "1-2", out); + expect(await qpdfPageCount(out)).toBe(2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rotates pages (output stays valid with same page count)", async () => { + const dir = mkdtempSync(join(tmpdir(), "pdf-ops-")); + try { + const out = join(dir, "rotated.pdf"); + await qpdfRotate(PDF, 90, "1-z", out); + expect(await qpdfPageCount(out)).toBe(3); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects an invalid range", async () => { + const dir = mkdtempSync(join(tmpdir(), "pdf-ops-")); + try { + await expect(qpdfSplitRanges(PDF, "abc;rm", join(dir, "x.pdf"))).rejects.toThrow(/range/i); + await expect(qpdfSplitRanges(PDF, "-1", join(dir, "x.pdf"))).rejects.toThrow(/range/i); + await expect(qpdfSplitRanges(PDF, "--", join(dir, "x.pdf"))).rejects.toThrow(/range/i); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe.skipIf(!gsAvailable())("doc-engine ghostscript compress (requires gs)", () => { + it("produces a smaller-or-equal valid pdf", async () => { + const dir = mkdtempSync(join(tmpdir(), "pdf-ops-")); + try { + const out = join(dir, "compressed.pdf"); + await gsCompressPdf(PDF, out, "ebook"); + const inBytes = await readFile(PDF); + const outBytes = await readFile(out); + expect(outBytes.subarray(0, 5).toString()).toBe("%PDF-"); + expect(outBytes.length).toBeLessThanOrEqual(inBytes.length * 2); // tiny fixtures can grow; validity is the real assertion + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/integration/extract-audio.test.ts b/tests/integration/extract-audio.test.ts new file mode 100644 index 00000000..239ca0a5 --- /dev/null +++ b/tests/integration/extract-audio.test.ts @@ -0,0 +1,64 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { ffmpegAvailable } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4")); + +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(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/extract-audio", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("extract-audio (requires ffmpeg)", () => { + it("extracts audio from mp4 as mp3 and returns 200", async () => { + const res = await runTool({ format: "mp3" }); + 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.length).toBeGreaterThan(100); + const outName = envelope.downloadUrl.split("/").pop() as string; + expect(outName.endsWith(".mp3")).toBe(true); + }, 60_000); + + it("extracts audio from mp4 as wav and returns 200", async () => { + const res = await runTool({ format: "wav" }); + 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.length).toBeGreaterThan(100); + const outName = envelope.downloadUrl.split("/").pop() as string; + expect(outName.endsWith(".wav")).toBe(true); + }, 60_000); +}); diff --git a/tests/integration/factory-multi-input.test.ts b/tests/integration/factory-multi-input.test.ts new file mode 100644 index 00000000..c58ce3bd --- /dev/null +++ b/tests/integration/factory-multi-input.test.ts @@ -0,0 +1,176 @@ +/** + * Integration tests for multi-file upload support via the factory's + * maxInputs config. Registers a test tool with maxInputs: 3 that + * concatenates all inputs; verifies the HTTP multipart path through + * the factory (route registration, file collection, per-file prepare, + * inputRefs on the durable row, and the too-many-files rejection). + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { db, schema } from "../../apps/api/src/db/index.js"; +import { createToolRoute } from "../../apps/api/src/routes/tool-factory.js"; +import { + buildTestApp, + createMultipartPayload, + loginAsAdmin, + preReadyHooks, + type TestApp, +} from "./test-server.js"; + +const PNG = readFileSync(join(__dirname, "..", "fixtures", "test-1x1.png")); +const PNG_A = readFileSync(join(__dirname, "..", "fixtures", "test-1x1.png")); +const PNG_B = readFileSync(join(__dirname, "..", "fixtures", "test-200x150.png")); +const PNG_C = readFileSync(join(__dirname, "..", "fixtures", "test-blank.png")); + +// Minimal schema stand-in that satisfies the factory's safeParse call +// without pulling in a zod dependency at the test root. +const emptySchema = { + safeParse: (v: unknown) => ({ success: true as const, data: v }), + parse: (v: unknown) => v, +} as never; + +// Register the test tool via a pre-ready hook so createToolRoute can +// call app.post() before Fastify is ready (Fastify forbids late routes). +preReadyHooks.push((app) => { + createToolRoute(app, { + toolId: "multi-concat", + maxInputs: 3, + settingsSchema: emptySchema, + process: async () => { + throw new Error("legacy path must not run"); + }, + processV2: async (ctx) => ({ + buffer: Buffer.concat(ctx.inputs.map((i) => i.buffer)), + filename: "combined.bin", + contentType: "application/octet-stream", + }), + }); +}); + +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("Factory multi-input (maxInputs)", () => { + it("accepts 3 files for a maxInputs:3 tool, concatenates, and records 3 inputRefs", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG_A }, + { name: "file", filename: "b.png", contentType: "image/png", content: PNG_B }, + { name: "file", filename: "c.png", contentType: "image/png", content: PNG_C }, + { name: "settings", content: "{}" }, + ]); + + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/multi-concat", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + expect(result.jobId).toBeDefined(); + + // Download and verify the output is the concatenation of three PNGs + const dl = await testApp.app.inject({ + method: "GET", + url: result.downloadUrl, + }); + expect(dl.statusCode).toBe(200); + + const expected = Buffer.concat([PNG_A, PNG_B, PNG_C]); + expect(dl.rawPayload.length).toBe(expected.length); + expect(dl.rawPayload.equals(expected)).toBe(true); + + // Verify the durable DB row has 3 inputRefs + const [row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, result.jobId)); + expect(row).toBeDefined(); + expect(row?.status).toBe("completed"); + expect(row?.inputRefs).toBeDefined(); + expect((row?.inputRefs as string[]).length).toBe(3); + }, 30_000); + + it("rejects 2 files on a tool without maxInputs (default 1)", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "b.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/resize", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + const result = JSON.parse(res.body); + expect(result.error).toMatch(/too many files/i); + }); + + it("rejects 4 files on a maxInputs:3 tool with the correct limit message", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "b.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "c.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "d.png", contentType: "image/png", content: PNG }, + { name: "settings", content: "{}" }, + ]); + + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/multi-concat", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + const result = JSON.parse(res.body); + expect(result.error).toBe("Too many files (max 3)"); + }); + + it("prefixes validation error with filename for multi-input tools", async () => { + const garbage = Buffer.from(Array.from({ length: 64 }, () => Math.floor(Math.random() * 256))); + + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "good.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "bad.png", contentType: "image/png", content: garbage }, + { name: "settings", content: "{}" }, + ]); + + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/multi-concat", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + const result = JSON.parse(res.body); + expect(result.error).toMatch(/^bad\.png:/); + }); +}); diff --git a/tests/integration/json-xml.test.ts b/tests/integration/json-xml.test.ts new file mode 100644 index 00000000..bfb9ea44 --- /dev/null +++ b/tests/integration/json-xml.test.ts @@ -0,0 +1,71 @@ +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 JSON_FIXTURE = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.json")); +const XML_FIXTURE = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.xml")); + +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/json-xml", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe("json-xml (pure JS, no skipIf)", () => { + it("converts JSON to XML containing Ada", async () => { + const res = await runTool("tiny.json", JSON_FIXTURE, { 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 xmlText = dl.payload; + expect(xmlText).toContain("Ada"); + }, 30_000); + + it("converts XML to JSON with the people structure", async () => { + const res = await runTool("tiny.xml", XML_FIXTURE, { 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); + // fast-xml-parser parses into a structure with a "people" key + expect(data.people).toBeDefined(); + const persons = data.people.person; + expect(Array.isArray(persons)).toBe(true); + const ada = persons.find((p: Record) => p.name === "Ada"); + expect(ada).toBeDefined(); + }, 30_000); +}); diff --git a/tests/integration/merge-pdf.test.ts b/tests/integration/merge-pdf.test.ts new file mode 100644 index 00000000..358f5795 --- /dev/null +++ b/tests/integration/merge-pdf.test.ts @@ -0,0 +1,77 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { qpdfAvailable, qpdfPageCount } from "@snapotter/doc-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const PDF = readFileSync(join(__dirname, "..", "fixtures", "test-3page.pdf")); + +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.skipIf(!qpdfAvailable())("merge-pdf (requires qpdf)", () => { + it("merges two PDFs into a 6-page document", async () => { + // Send TWO file parts named "file" (the multi-input path) + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "a.pdf", contentType: "application/pdf", content: PDF }, + { name: "file", filename: "b.pdf", contentType: "application/pdf", content: PDF }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/merge-pdf", + 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); + expect(dl.rawPayload.length).toBeGreaterThan(100); + + // Write downloaded PDF to temp and verify page count + const dir = mkdtempSync(join(tmpdir(), "merge-pdf-test-")); + try { + const outPath = join(dir, "merged.pdf"); + writeFileSync(outPath, dl.rawPayload); + const pages = await qpdfPageCount(outPath); + expect(pages).toBe(6); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, 60_000); + + it("returns 422 when only one PDF is provided", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "only.pdf", contentType: "application/pdf", content: PDF }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/merge-pdf", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + // The worker throws "Merging needs at least two PDFs" which surfaces as 422 + // with a generic "Processing failed" error (the factory strips internal details) + expect(res.statusCode).toBe(422); + const parsed = JSON.parse(res.body); + expect(parsed.error).toBe("Processing failed"); + expect(parsed.details).toMatch(/at least two/i); + }, 60_000); +}); diff --git a/tests/integration/modality-input.test.ts b/tests/integration/modality-input.test.ts index 727b5545..f711cd02 100644 --- a/tests/integration/modality-input.test.ts +++ b/tests/integration/modality-input.test.ts @@ -26,6 +26,19 @@ describe.skipIf(!ffmpegAvailable())("MediaInputHandler (requires ffmpeg)", () => ).rejects.toThrow(InputValidationError); }); + it("rejects a still image presented as video", async () => { + const buf = await readFile(join(process.cwd(), "tests/fixtures/test-1x1.png")); + await expect( + new MediaInputHandler("video").prepare(buf, "fake.mp4", { scratchDir }), + ).rejects.toThrow(/still image/i); + }); + + it("accepts a real video despite short duration", async () => { + const buf = await readFile(join(process.cwd(), "tests/fixtures/media/tiny.mp4")); + const out = await new MediaInputHandler("video").prepare(buf, "tiny.mp4", { scratchDir }); + expect(out.filename).toBe("tiny.mp4"); + }); + it("enforces the duration cap", async () => { const { env } = await import("../../apps/api/src/config.js"); const original = env.MAX_AUDIO_DURATION_S; diff --git a/tests/integration/mute-video.test.ts b/tests/integration/mute-video.test.ts new file mode 100644 index 00000000..f14bed4e --- /dev/null +++ b/tests/integration/mute-video.test.ts @@ -0,0 +1,59 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { ffmpegAvailable } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4")); + +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.skipIf(!ffmpegAvailable())("mute-video (requires ffmpeg)", () => { + it("removes audio and returns 200 with settings {}", async () => { + // Verify the factory default for empty settings schema: send NO settings part + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/mute-video", + 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); + expect(dl.rawPayload.length).toBeGreaterThan(100); + }, 60_000); + + it("also works with explicit empty settings", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/mute-video", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + }, 60_000); +}); diff --git a/tests/integration/rotate-pdf.test.ts b/tests/integration/rotate-pdf.test.ts new file mode 100644 index 00000000..b9e137ac --- /dev/null +++ b/tests/integration/rotate-pdf.test.ts @@ -0,0 +1,65 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { qpdfAvailable, qpdfPageCount } from "@snapotter/doc-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const PDF = readFileSync(join(__dirname, "..", "fixtures", "test-3page.pdf")); + +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(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test-3page.pdf", contentType: "application/pdf", content: PDF }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/rotate-pdf", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!qpdfAvailable())("rotate-pdf (requires qpdf)", () => { + it("rotates 90 degrees and preserves page count", async () => { + const res = await runTool({ angle: 90 }); + 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); + + // Write downloaded PDF to temp and verify page count + const dir = mkdtempSync(join(tmpdir(), "rotate-pdf-test-")); + try { + const outPath = join(dir, "rotated.pdf"); + writeFileSync(outPath, dl.rawPayload); + const pages = await qpdfPageCount(outPath); + expect(pages).toBe(3); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, 60_000); + + it("rejects an invalid page range with 400", async () => { + const res = await runTool({ angle: 90, range: "abc;x" }); + // Schema-level regex validation returns 400 + expect(res.statusCode).toBe(400); + }, 60_000); +}); diff --git a/tests/integration/split-csv.test.ts b/tests/integration/split-csv.test.ts new file mode 100644 index 00000000..cbcdd144 --- /dev/null +++ b/tests/integration/split-csv.test.ts @@ -0,0 +1,80 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import AdmZip from "adm-zip"; +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")); + +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(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.csv", contentType: "text/csv", content: CSV }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/split-csv", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe("split-csv (pure JS, no skipIf)", () => { + it("splits with rowsPerFile 1 into a zip with 3 entries each containing the header", async () => { + const res = await runTool({ rowsPerFile: 1, keepHeader: 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); + + // Verify zip magic (PK header: 0x50 0x4B) + expect(dl.rawPayload[0]).toBe(0x50); + expect(dl.rawPayload[1]).toBe(0x4b); + + // Verify 3 entries via adm-zip (3 data rows = 3 parts) + const zip = new AdmZip(Buffer.from(dl.rawPayload)); + const entries = zip.getEntries(); + expect(entries.length).toBe(3); + + // Each part should contain the header row "name,age" + for (const entry of entries) { + const text = entry.getData().toString("utf8"); + expect(text).toContain("name,age"); + } + }, 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); + 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(); + // 4 rows (including header treated as data) / 2 = 2 parts + expect(entries.length).toBe(2); + }, 30_000); +}); diff --git a/tests/integration/split-pdf.test.ts b/tests/integration/split-pdf.test.ts new file mode 100644 index 00000000..7175d118 --- /dev/null +++ b/tests/integration/split-pdf.test.ts @@ -0,0 +1,97 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { qpdfAvailable, qpdfPageCount } from "@snapotter/doc-engine"; +import AdmZip from "adm-zip"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const PDF = readFileSync(join(__dirname, "..", "fixtures", "test-3page.pdf")); + +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(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test-3page.pdf", contentType: "application/pdf", content: PDF }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/split-pdf", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!qpdfAvailable())("split-pdf (requires qpdf)", () => { + it("extracts a page range into a 2-page PDF", async () => { + const res = await runTool({ mode: "range", range: "1-2" }); + 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); + + // Verify page count by writing to temp + const { mkdtempSync, writeFileSync, rmSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const dir = mkdtempSync(join(tmpdir(), "split-pdf-test-")); + try { + const outPath = join(dir, "split.pdf"); + writeFileSync(outPath, dl.rawPayload); + const pages = await qpdfPageCount(outPath); + expect(pages).toBe(2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, 60_000); + + it("splits every 1 page into a zip with 3 entries", async () => { + const res = await runTool({ mode: "every", everyN: 1 }); + 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); + + // Verify zip magic (PK header: 0x50 0x4B) + expect(dl.rawPayload[0]).toBe(0x50); + expect(dl.rawPayload[1]).toBe(0x4b); + + // Verify 3 entries via adm-zip + const zip = new AdmZip(Buffer.from(dl.rawPayload)); + const entries = zip.getEntries(); + expect(entries.length).toBe(3); + }, 60_000); + + it("rejects an invalid range at the schema level", async () => { + const res = await runTool({ mode: "range", range: "abc;x" }); + expect(res.statusCode).toBe(400); + const parsed = JSON.parse(res.body); + expect(parsed.error).toBe("Invalid settings"); + }); + + it("rejects range mode without a range field", async () => { + const res = await runTool({ mode: "range" }); + expect(res.statusCode).toBe(400); + const parsed = JSON.parse(res.body); + expect(parsed.error).toBe("Invalid settings"); + }); +}); diff --git a/tests/integration/test-server.ts b/tests/integration/test-server.ts index 987cd028..59922da1 100644 --- a/tests/integration/test-server.ts +++ b/tests/integration/test-server.ts @@ -93,6 +93,14 @@ afterAll(async () => { // --------------------------------------------------------------------------- // 3. Public API // --------------------------------------------------------------------------- + +/** + * Pre-ready hooks: test files can push registrars here before calling + * buildTestApp(). Each hook receives the Fastify instance and can register + * extra routes (createToolRoute, etc.) before app.ready() is called. + */ +export const preReadyHooks: Array<(app: ReturnType) => void | Promise> = []; + export interface TestApp { app: ReturnType; cleanup: () => Promise; @@ -258,6 +266,12 @@ export async function buildTestApp(): Promise { }, ); + // Run pre-ready hooks (test files register extra routes here) + for (const hook of preReadyHooks) { + await hook(app); + } + preReadyHooks.length = 0; + // Ensure Fastify is ready (all plugins loaded) await app.ready(); diff --git a/tests/integration/trim-audio.test.ts b/tests/integration/trim-audio.test.ts new file mode 100644 index 00000000..d5589d9e --- /dev/null +++ b/tests/integration/trim-audio.test.ts @@ -0,0 +1,64 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP3 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp3")); + +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(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/trim-audio", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("trim-audio (requires ffmpeg)", () => { + it("trims mp3 from 0 to 0.5s and returns 200", async () => { + const res = await runTool({ startS: 0, endS: 0.5 }); + 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.length).toBeGreaterThan(50); + + // Verify trimmed duration via probeMedia (mirror trim-video's pattern) + const tmpDir = mkdtempSync(join(tmpdir(), "trim-audio-test-")); + const probeFile = join(tmpDir, "trimmed.mp3"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + expect(info.durationS).not.toBeNull(); + // Stream-copy on mp3 may overshoot slightly due to frame boundaries, + // but should be well under the original 1s duration. + expect(info.durationS as number).toBeLessThanOrEqual(2); + expect(info.durationS as number).toBeGreaterThan(0); + }, 60_000); + + it("rejects when end is before start", async () => { + const res = await runTool({ startS: 0.5, endS: 0.2 }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/integration/trim-video.test.ts b/tests/integration/trim-video.test.ts new file mode 100644 index 00000000..c02050d7 --- /dev/null +++ b/tests/integration/trim-video.test.ts @@ -0,0 +1,77 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4")); + +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(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/trim-video", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("trim-video (requires ffmpeg)", () => { + it("trims a clip (fast, stream-copy) and returns 200", async () => { + const res = await runTool({ startS: 0, endS: 0.5 }); + 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.length).toBeGreaterThan(100); + + // Verify trimmed duration via probeMedia (the plan's verify-don't-trust point) + const tmpDir = mkdtempSync(join(tmpdir(), "trim-test-")); + const probeFile = join(tmpDir, "trimmed.mp4"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + // The trimmed file should be approximately 0.5s (stream-copy may be + // slightly longer due to keyframe alignment, but well under 2s). + expect(info.durationS).not.toBeNull(); + expect(info.durationS as number).toBeLessThanOrEqual(2); + expect(info.durationS as number).toBeGreaterThan(0); + }, 60_000); + + it("trims with precise re-encode and returns 200", async () => { + const res = await runTool({ startS: 0, endS: 0.5, precise: 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); + expect(dl.rawPayload.length).toBeGreaterThan(100); + }, 60_000); + + it("rejects when end is before start", async () => { + const res = await runTool({ startS: 0.5, endS: 0.2 }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/integration/video-to-gif.test.ts b/tests/integration/video-to-gif.test.ts new file mode 100644 index 00000000..eaa41935 --- /dev/null +++ b/tests/integration/video-to-gif.test.ts @@ -0,0 +1,60 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { ffmpegAvailable } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4")); + +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 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 } | 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(!ffmpegAvailable())("video-to-gif (requires ffmpeg)", () => { + it("returns 202 (long hint) and produces a GIF with GIF8 magic", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "settings", content: JSON.stringify({ fps: 8, width: 120, durationS: 1 }) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/video-to-gif", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + 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; + expect(outName.endsWith(".gif")).toBe(true); + const dl = await testApp.app.inject({ + method: "GET", + url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`, + }); + expect(dl.statusCode).toBe(200); + // GIF files start with GIF8 magic bytes + const magic = dl.rawPayload.subarray(0, 4).toString("ascii"); + expect(magic).toBe("GIF8"); + }, 90_000); +}); diff --git a/tests/integration/word-to-pdf.test.ts b/tests/integration/word-to-pdf.test.ts new file mode 100644 index 00000000..f22781f7 --- /dev/null +++ b/tests/integration/word-to-pdf.test.ts @@ -0,0 +1,62 @@ +// word-to-pdf integration suite. +// Requires LibreOffice (soffice). Skips locally (soffice absent on dev Macs); +// the Task 14 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")); + +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/word-to-pdf", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!sofficeAvailable())("word-to-pdf (requires soffice)", () => { + it("returns 202 (long hint) and the job completes with a PDF", async () => { + const res = await runTool("tiny.docx", DOCX); + 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); +}); diff --git a/tests/unit/api/tool-factory-route.test.ts b/tests/unit/api/tool-factory-route.test.ts index 4677134f..07cce941 100644 --- a/tests/unit/api/tool-factory-route.test.ts +++ b/tests/unit/api/tool-factory-route.test.ts @@ -328,7 +328,7 @@ describe("createToolRoute", () => { expect(reply.status).toHaveBeenCalledWith(400); expect(reply.send).toHaveBeenCalledWith( expect.objectContaining({ - error: expect.stringContaining("one image at a time"), + error: "Too many files (max 1)", }), ); }); diff --git a/tests/unit/landing/bento-grid.test.tsx b/tests/unit/landing/bento-grid.test.tsx index fb720420..67133c43 100644 --- a/tests/unit/landing/bento-grid.test.tsx +++ b/tests/unit/landing/bento-grid.test.tsx @@ -14,6 +14,10 @@ vi.mock("framer-motion", () => ({ })); import { BentoGrid } from "@landing/components/bento-grid"; +import { CATEGORIES, TOOLS } from "@snapotter/shared"; + +const TOTAL = TOOLS.length; +const AI_COUNT = TOOLS.filter((t) => t.category === "ai").length; afterEach(cleanup); @@ -36,12 +40,12 @@ describe("BentoGrid", () => { it("shows all tools by default", () => { render(); const text = getCountText(); - expect(text).toMatch(/Showing 53 of 53 tools/); + expect(text).toBe(`Showing ${TOTAL} of ${TOTAL} tools`); }); it("renders category filter pills including All", () => { render(); - expect(screen.getByText((_, el) => el?.textContent === "All (53)")).toBeDefined(); + expect(screen.getByText((_, el) => el?.textContent === `All (${TOTAL})`)).toBeDefined(); expect(screen.getByText(/Essentials/)).toBeDefined(); expect(screen.getByText(/AI Tools/)).toBeDefined(); expect(screen.getByText(/Optimization/)).toBeDefined(); @@ -53,7 +57,7 @@ describe("BentoGrid", () => { fireEvent.change(input, { target: { value: "resize" } }); expect(screen.getByText("Resize")).toBeDefined(); const text = getCountText(); - expect(text).toMatch(/Showing \d+ of 53 tools/); + expect(text).toMatch(new RegExp(`Showing \\d+ of ${TOTAL} tools`)); expect(screen.queryByText("OCR / Text Extraction")).toBeNull(); }); @@ -62,7 +66,7 @@ describe("BentoGrid", () => { const aiButton = screen.getByText(/AI Tools/); fireEvent.click(aiButton); const text = getCountText(); - expect(text).toMatch(/Showing 16 of 53 tools/); + expect(text).toBe(`Showing ${AI_COUNT} of ${TOTAL} tools`); expect(screen.getByText("Remove Background")).toBeDefined(); expect(screen.queryByText("Resize")).toBeNull(); }); @@ -73,7 +77,7 @@ describe("BentoGrid", () => { fireEvent.change(input, { target: { value: "xyznonexistent" } }); expect(screen.getByText("No tools found. Try a different search.")).toBeDefined(); const text = getCountText(); - expect(text).toMatch(/Showing 0 of 53 tools/); + expect(text).toBe(`Showing 0 of ${TOTAL} tools`); }); it("combines search and category filter", () => { @@ -89,9 +93,9 @@ describe("BentoGrid", () => { it("clicking All resets category filter", () => { render(); fireEvent.click(screen.getByText(/AI Tools/)); - expect(getCountText()).toMatch(/Showing 16 of 53 tools/); - fireEvent.click(screen.getByText((_, el) => el?.textContent === "All (53)")); - expect(getCountText()).toMatch(/Showing 53 of 53 tools/); + expect(getCountText()).toBe(`Showing ${AI_COUNT} of ${TOTAL} tools`); + fireEvent.click(screen.getByText((_, el) => el?.textContent === `All (${TOTAL})`)); + expect(getCountText()).toBe(`Showing ${TOTAL} of ${TOTAL} tools`); }); it("renders each tool with name and description", () => { @@ -100,20 +104,13 @@ describe("BentoGrid", () => { expect(screen.getByText("Freeform crop, aspect ratio presets, shape crop")).toBeDefined(); }); - it("renders all 8 category pills", () => { + it("renders all category pills", () => { render(); - const categoryNames = [ - "Essentials", - "Optimization", - "Adjustments", - "AI Tools", - "Watermark & Overlay", - "Utilities", - "Layout & Composition", - "Format & Conversion", - ]; - for (const name of categoryNames) { - expect(screen.getByText(new RegExp(name))).toBeDefined(); + for (const cat of CATEGORIES) { + const count = TOOLS.filter((t) => t.category === cat.id).length; + expect( + screen.getByText((_, el) => el?.textContent === `${cat.name} (${count})`), + ).toBeDefined(); } }); }); diff --git a/tests/unit/shared/modality.test.ts b/tests/unit/shared/modality.test.ts index 6b31028c..507a9fbb 100644 --- a/tests/unit/shared/modality.test.ts +++ b/tests/unit/shared/modality.test.ts @@ -18,16 +18,25 @@ describe("modality metadata", () => { expect(MODALITY_POOL.file).toBe("docs"); }); - it("every tool declares modality, acceptedInputs and executionHint", () => { + it("every tool declares a valid modality, acceptedInputs and executionHint", () => { + const validModalities = ["image", "video", "audio", "document", "file"]; expect(TOOLS.length).toBeGreaterThanOrEqual(53); for (const tool of TOOLS) { - expect(tool.modality).toBe("image"); // phase 3: image only + expect(validModalities).toContain(tool.modality); expect(Array.isArray(tool.acceptedInputs)).toBe(true); expect(tool.acceptedInputs.length).toBeGreaterThan(0); expect(["fast", "long"]).toContain(tool.executionHint); } }); + it("pdf-to-image is a document-modality tool", () => { + const pdfToImage = TOOLS.find((t) => t.id === "pdf-to-image"); + expect(pdfToImage).toBeDefined(); + expect(pdfToImage!.modality).toBe("document"); + expect(pdfToImage!.category).toBe("documents"); + expect(pdfToImage!.acceptedInputs).toEqual([".pdf"]); + }); + it("AI tools are hinted long (except pure-CV ones)", () => { const ai = TOOLS.filter((t) => t.category === "ai"); expect(ai.length).toBeGreaterThan(0); diff --git a/tests/unit/web/i18n-locale.test.ts b/tests/unit/web/i18n-locale.test.ts index 7fee226e..b685d6e9 100644 --- a/tests/unit/web/i18n-locale.test.ts +++ b/tests/unit/web/i18n-locale.test.ts @@ -80,7 +80,7 @@ describe("en translation completeness", () => { expect(en.features.progressMessages).toHaveLength(30); }); - it("has 8 categories", () => { - expect(Object.keys(en.categories)).toHaveLength(8); + it("has 12 categories", () => { + expect(Object.keys(en.categories)).toHaveLength(12); }); }); diff --git a/tests/unit/web/tool-registry-expanded.test.ts b/tests/unit/web/tool-registry-expanded.test.ts index b41deb78..a9d1dc34 100644 --- a/tests/unit/web/tool-registry-expanded.test.ts +++ b/tests/unit/web/tool-registry-expanded.test.ts @@ -141,6 +141,33 @@ vi.mock("@/components/tools/color-blindness-settings", () => ({ vi.mock("@/components/tools/ai-canvas-expand-settings", () => ({ AiCanvasExpandSettings: () => null, })); +vi.mock("@/components/tools/merge-pdf-settings", () => ({ + MergePdfSettings: () => null, +})); +vi.mock("@/components/tools/split-pdf-settings", () => ({ + SplitPdfSettings: () => null, +})); +vi.mock("@/components/tools/compress-pdf-settings", () => ({ + CompressPdfSettings: () => null, +})); +vi.mock("@/components/tools/rotate-pdf-settings", () => ({ + RotatePdfSettings: () => null, +})); +vi.mock("@/components/tools/word-to-pdf-settings", () => ({ + WordToPdfSettings: () => null, +})); +vi.mock("@/components/tools/csv-excel-settings", () => ({ + CsvExcelSettings: () => null, +})); +vi.mock("@/components/tools/csv-json-settings", () => ({ + CsvJsonSettings: () => null, +})); +vi.mock("@/components/tools/json-xml-settings", () => ({ + JsonXmlSettings: () => null, +})); +vi.mock("@/components/tools/split-csv-settings", () => ({ + SplitCsvSettings: () => null, +})); import { TOOLS } from "@snapotter/shared"; import type { DisplayMode, ToolRegistryEntry } from "@/lib/tool-registry"; diff --git a/tests/unit/web/tool-registry.test.ts b/tests/unit/web/tool-registry.test.ts index e26cfd96..5c80f617 100644 --- a/tests/unit/web/tool-registry.test.ts +++ b/tests/unit/web/tool-registry.test.ts @@ -184,6 +184,33 @@ vi.mock("@/components/tools/color-blindness-settings", () => ({ vi.mock("@/components/tools/ai-canvas-expand-settings", () => ({ AiCanvasExpandSettings: () => null, })); +vi.mock("@/components/tools/merge-pdf-settings", () => ({ + MergePdfSettings: () => null, +})); +vi.mock("@/components/tools/split-pdf-settings", () => ({ + SplitPdfSettings: () => null, +})); +vi.mock("@/components/tools/compress-pdf-settings", () => ({ + CompressPdfSettings: () => null, +})); +vi.mock("@/components/tools/rotate-pdf-settings", () => ({ + RotatePdfSettings: () => null, +})); +vi.mock("@/components/tools/word-to-pdf-settings", () => ({ + WordToPdfSettings: () => null, +})); +vi.mock("@/components/tools/csv-excel-settings", () => ({ + CsvExcelSettings: () => null, +})); +vi.mock("@/components/tools/csv-json-settings", () => ({ + CsvJsonSettings: () => null, +})); +vi.mock("@/components/tools/json-xml-settings", () => ({ + JsonXmlSettings: () => null, +})); +vi.mock("@/components/tools/split-csv-settings", () => ({ + SplitCsvSettings: () => null, +})); // --------------------------------------------------------------------------- // Import after mocks @@ -289,6 +316,8 @@ describe("toolRegistry", () => { "interactive-split", "no-dropzone", "custom-results", + "media-player", + "document", ]; for (const [toolId, entry] of toolRegistry) { expect(validModes, `invalid displayMode for ${toolId}`).toContain(entry.displayMode);