diff --git a/db/migrations/25-12-06-add-export-job-types.sql b/db/migrations/25-12-06-add-export-job-types.sql new file mode 100644 index 00000000..02e62a3f --- /dev/null +++ b/db/migrations/25-12-06-add-export-job-types.sql @@ -0,0 +1,9 @@ +select setval( + pg_get_serial_sequence('job_type', 'id'), + (select coalesce(max(id), 0) from job_type) +); + +insert into job_type (name) +values + ('export_interlinear_pdf') +on conflict (name) do nothing; diff --git a/package-lock.json b/package-lock.json index efcb2283..703e0447 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "@fortawesome/react-fontawesome": "0.2.2", "@headlessui/react": "1.7.19", "@headlessui/tailwindcss": "0.2.1", + "@testing-library/react": "16.3.1", "@tiptap/react": "2.6.6", "@tiptap/starter-kit": "2.6.6", "@types/aws-lambda": "8.10.147", @@ -399,6 +400,7 @@ "version": "3.662.0", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -451,6 +453,7 @@ "version": "3.662.0", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -896,6 +899,7 @@ "node_modules/@aws-sdk/client-sso-oidc": { "version": "3.678.0", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -947,6 +951,7 @@ "node_modules/@aws-sdk/client-sts": { "version": "3.678.0", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -1590,6 +1595,41 @@ "node": ">=16.0.0" } }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@emnapi/core": { "version": "0.45.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-0.45.0.tgz", @@ -2162,6 +2202,7 @@ "version": "6.6.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" }, @@ -3111,6 +3152,7 @@ "node_modules/@opentelemetry/api": { "version": "1.9.0", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -4658,10 +4700,69 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz", + "integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tiptap/core": { "version": "2.6.6", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -4927,6 +5028,7 @@ "version": "2.6.6", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.2.1", "prosemirror-collab": "^1.3.1", @@ -5020,6 +5122,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/aws-lambda": { "version": "8.10.147", "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.147.tgz", @@ -5124,6 +5233,7 @@ "version": "18.3.3", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -5133,6 +5243,7 @@ "version": "18.3.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/react": "*" } @@ -5433,6 +5544,7 @@ "node_modules/acorn": { "version": "8.12.1", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6080,6 +6192,7 @@ "version": "4.4.4", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -6447,6 +6560,7 @@ "node_modules/date-fns": { "version": "4.1.0", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -6555,6 +6669,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "dev": true, @@ -6587,6 +6711,13 @@ "node": ">=6.0.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dompurify": { "version": "3.1.6", "license": "(MPL-2.0 OR Apache-2.0)" @@ -6906,6 +7037,7 @@ "version": "8.57.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7064,6 +7196,7 @@ "version": "2.29.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "array-includes": "^3.1.7", "array.prototype.findlastindex": "^1.2.3", @@ -9329,6 +9462,16 @@ "dev": true, "license": "ISC" }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", @@ -10115,6 +10258,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", "integrity": "sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.6.4", "pg-pool": "^3.6.2", @@ -10446,6 +10590,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", @@ -10644,6 +10789,41 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/process-warning": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", @@ -10770,6 +10950,7 @@ "version": "1.22.3", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -10796,6 +10977,7 @@ "version": "1.4.3", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -10840,6 +11022,7 @@ "version": "1.34.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -10954,6 +11137,7 @@ "node_modules/react": { "version": "18.3.1", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -10964,6 +11148,7 @@ "node_modules/react-dom": { "version": "18.3.1", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -11971,6 +12156,7 @@ "version": "3.4.10", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -12335,6 +12521,7 @@ "version": "5.5.4", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12466,6 +12653,7 @@ "integrity": "sha512-4VL9mQPKoHy4+FE0NnRE/kbY51TOfaknxAjt3fJbGJxhIpBZiqVzlZDEesWWsuREXHwNdAoOFZ9MkPEVXczHwg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.24.2", "postcss": "^8.4.49", diff --git a/package.json b/package.json index da3bafcd..fe649937 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@fortawesome/react-fontawesome": "0.2.2", "@headlessui/react": "1.7.19", "@headlessui/tailwindcss": "0.2.1", + "@testing-library/react": "16.3.1", "@tiptap/react": "2.6.6", "@tiptap/starter-kit": "2.6.6", "@types/aws-lambda": "8.10.147", diff --git a/src/app/[locale]/(main)/admin/(language)/languages/[code]/exports/page.tsx b/src/app/[locale]/(main)/admin/(language)/languages/[code]/exports/page.tsx new file mode 100644 index 00000000..3a14cc96 --- /dev/null +++ b/src/app/[locale]/(main)/admin/(language)/languages/[code]/exports/page.tsx @@ -0,0 +1 @@ +export { default } from "@/modules/export/react/LanguageExportsPage"; diff --git a/src/app/[locale]/(main)/admin/(language)/languages/[code]/exports/progress/route.ts b/src/app/[locale]/(main)/admin/(language)/languages/[code]/exports/progress/route.ts new file mode 100644 index 00000000..61041169 --- /dev/null +++ b/src/app/[locale]/(main)/admin/(language)/languages/[code]/exports/progress/route.ts @@ -0,0 +1 @@ +export { default as GET } from "@/modules/export/route-handlers/getExportProgress"; diff --git a/src/app/[locale]/(main)/features/page.tsx b/src/app/[locale]/(main)/features/page.tsx index c7f6c7fb..96dce010 100644 --- a/src/app/[locale]/(main)/features/page.tsx +++ b/src/app/[locale]/(main)/features/page.tsx @@ -11,6 +11,10 @@ export default function FeaturesPage() { > Features + ); diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 334c6c8e..7348e9b4 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -30,7 +30,9 @@ library.add( FaSolid.faArrowRight, FaSolid.faRightFromBracket, FaSolid.faExclamationTriangle, + FaSolid.faFileArrowDown, FaSolid.faFileImport, + FaSolid.faDownload, FaSolid.faChevronUp, FaSolid.faChevronDown, FaSolid.faChevronRight, diff --git a/src/feature-flags.ts b/src/feature-flags.ts index 53ea333a..79616f01 100644 --- a/src/feature-flags.ts +++ b/src/feature-flags.ts @@ -1,6 +1,6 @@ import { useLayoutEffect, useState } from "react"; -export type Feature = "ff-snapshots"; // string union of available flags +export type Feature = "ff-snapshots" | "ff-interlinear-pdf-export"; // string union of available flags export const features: Feature[] = []; diff --git a/src/messages/ar.json b/src/messages/ar.json index 78284c2e..567b6f58 100644 --- a/src/messages/ar.json +++ b/src/messages/ar.json @@ -156,9 +156,13 @@ "users": "المستخدمون", "reports": "التقارير", "import": "استيراد", + "exports": "التصدير", "snapshots": "لقطات" } }, + "LanguageExportsPage": { + "title": "التصدير" + }, "LanguageSettingsPage": { "title": "الإعدادات", "form": { @@ -652,5 +656,31 @@ "reference": "{bookId, select, 1 {التكوين} 2 {الخروج} 3 {اللاويين} 4 {العدد} 5 {التثنية} 6 {يشوع} 7 {القضاة} 8 {راعوث} 9 {صموئيل الأول} 10 {صموئيل الثاني} 11 {الملوك الأول} 12 {الملوك الثاني} 13 {أخبار الأيام الأول} 14 {أخبار الأيام الثاني} 15 {عزرا} 16 {نحميا} 17 {استير} 18 {أيوب} 19 {المزامير} 20 {الأمثال} 21 {الجامعة} 22 {نشيد الأنشاد} 23 {إشعياء} 24 {أرميا} 25 {مراثي أرميا} 26 {حزقيال} 27 {دانيال} 28 {هوشع} 29 {يوئيل} 30 {عاموس} 31 {عوبديا} 32 {يونان} 33 {ميخا} 34 {ناحوم} 35 {حبقوق} 36 {صفنيا} 37 {حجاي} 38 {زكريا} 39 {ملاخي} 40 {متى} 41 {مرقس} 42 {لوقا} 43 {يوحنا} 44 {أعمال الرسل} 45 {رومية} 46 {كورنثوس الأولى} 47 {كورنثوس الثانية} 48 {غلاطية} 49 {أفسس} 50 {فيلبي} 51 {كولوسي} 52 {تسالونيكي الأولى} 53 {تسالونيكي الثانية} 54 {تيموثاوس الأولى} 55 {تيموثاوس الثانية} 56 {تيطس} 57 {فليمون} 58 {العبرانيين} 59 {يعقوب} 60 {بطرس الأولى} 61 {بطرس الثانية} 62 {يوحنا الأولى} 63 {يوحنا الثانية} 64 {يوحنا الثالثة} 65 {يهوذا} 66 {رؤيا} other {}} {chapterNumber}:{verseNumber}", "not_found": "غير موجود", "close": "إغلاق" + }, + "InterlinearExport": { + "title": "تصدير PDF بين السطور", + "description": "إنشاء ملف PDF بين السطور لكل الأسفار.", + "form": { + "submit": "إنشاء PDF", + "queued": "تمت الإضافة للطابور..." + }, + "status": { + "title": "الحالة", + "download": "تنزيل PDF", + "expires": "ينتهي", + "generating": "جارٍ إنشاء PDF…", + "failed": "فشل التصدير. حاول مرة أخرى.", + "labels": { + "pending": "في الطابور", + "in_progress": "قيد المعالجة", + "complete": "مكتمل", + "error": "فشل" + } + }, + "errors": { + "invalid": "غير صالح", + "language_required": "اللغة مطلوبة.", + "export_failed": "فشل التصدير." + } } } diff --git a/src/messages/en.json b/src/messages/en.json index b8be680b..25fa5978 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -156,9 +156,13 @@ "users": "Users", "reports": "Reports", "import": "Import", + "exports": "Exports", "snapshots": "Snapshots" } }, + "LanguageExportsPage": { + "title": "Exports" + }, "LanguageSettingsPage": { "title": "Settings", "form": { @@ -655,5 +659,31 @@ "reference": "{bookId, select, 1 {Genesis} 2 {Exodus} 3 {Leviticus} 4 {Numbers} 5 {Deuteronomy} 6 {Joshua} 7 {Judges} 8 {Ruth} 9 {1 Samuel} 10 {2 Samuel} 11 {1 Kings} 12 {2 Kings} 13 {1 Chronicles} 14 {2 Chronicles} 15 {Ezra} 16 {Nehemiah} 17 {Esther} 18 {Job} 19 {Psalm} 20 {Proverbs} 21 {Ecclesiastes} 22 {Song of Songs} 23 {Isaiah} 24 {Jeremiah} 25 {Lamentations} 26 {Ezekiel} 27 {Daniel} 28 {Hosea} 29 {Joel} 30 {Amos} 31 {Obadiah} 32 {Jonah} 33 {Micah} 34 {Nahum} 35 {Habakkuk} 36 {Zephaniah} 37 {Haggai} 38 {Zechariah} 39 {Malachi} 40 {Matthew} 41 {Mark} 42 {Luke} 43 {John} 44 {Acts} 45 {Romans} 46 {1 Corinthians} 47 {2 Corinthians} 48 {Galatians} 49 {Ephesians} 50 {Philippians} 51 {Colossians} 52 {1 Thessalonians} 53 {2 Thessalonians} 54 {1 Timothy} 55 {2 Timothy} 56 {Titus} 57 {Philemon} 58 {Hebrews} 59 {James} 60 {1 Peter} 61 {2 Peter} 62 {1 John} 63 {2 John} 64 {3 John} 65 {Jude} 66 {Revelation} other {}} {chapterNumber}:{verseNumber}", "not_found": "Not Found", "close": "Close" + }, + "InterlinearExport": { + "title": "Interlinear PDF Export", + "description": "Generate a PDF interlinear for all books.", + "form": { + "submit": "Generate PDF", + "queued": "Queued..." + }, + "status": { + "title": "Status", + "download": "Download PDF", + "expires": "Expires", + "generating": "Generating PDF…", + "failed": "Export failed. Please try again.", + "labels": { + "pending": "Queued", + "in_progress": "In progress", + "complete": "Complete", + "error": "Failed" + } + }, + "errors": { + "invalid": "Invalid", + "language_required": "Language is required.", + "export_failed": "Export failed." + } } } diff --git a/src/modules/export/actions/requestInterlinearExport.test.ts b/src/modules/export/actions/requestInterlinearExport.test.ts new file mode 100644 index 00000000..09d918e9 --- /dev/null +++ b/src/modules/export/actions/requestInterlinearExport.test.ts @@ -0,0 +1,85 @@ +import "@/tests/vitest/mocks/nextjs"; +import { initializeDatabase } from "@/tests/vitest/dbUtils"; +import { expect, test, vi } from "vitest"; + +const { enqueueJobMock } = vi.hoisted(() => ({ + enqueueJobMock: vi.fn(), +})); + +vi.mock("@/shared/jobs/enqueueJob", () => ({ + enqueueJob: enqueueJobMock, +})); + +import { requestInterlinearExport } from "./requestInterlinearExport"; +import { createScenario } from "@/tests/scenarios"; +import logIn from "@/tests/vitest/login"; +import { enqueueJob } from "@/shared/jobs/enqueueJob"; +import { SystemRoleRaw } from "@/modules/users/model/SystemRole"; + +initializeDatabase(); + +test("rejects unauthenticated requests", async () => { + const formData = new FormData(); + await expect(requestInterlinearExport(formData)).toBeNextjsNotFound(); + expect(enqueueJob).not.toHaveBeenCalled(); +}); + +test("returns validation error for unknown language before enqueue", async () => { + const scenario = await createScenario({ + users: { admin: { systemRoles: [SystemRoleRaw.Admin] } }, + }); + await logIn(scenario.users.admin.id); + + const formData = new FormData(); + formData.set("languageCode", "missing"); + + const response = await requestInterlinearExport(formData); + expect(response).toEqual({ + state: "error", + error: "Export failed.", + }); + expect(enqueueJob).not.toHaveBeenCalled(); +}); + +test("denies non-members", async () => { + const scenario = await createScenario({ + users: { outsider: {} }, + languages: { language: {} }, + }); + const user = scenario.users.outsider; + const language = scenario.languages.language; + await logIn(user.id); + + const formData = new FormData(); + formData.set("languageCode", language.code); + + await expect(requestInterlinearExport(formData)).toBeNextjsNotFound(); + expect(enqueueJob).not.toHaveBeenCalled(); +}); + +test("creates export request for language", async () => { + const scenario = await createScenario({ + users: { translator: {} }, + languages: { + language: { + members: ["translator"], + }, + }, + }); + const user = scenario.users.translator; + const language = scenario.languages.language; + await logIn(user.id); + + const formData = new FormData(); + formData.set("languageCode", language.code); + + (enqueueJob as any).mockResolvedValueOnce({ id: "job-123" }); + const response = await requestInterlinearExport(formData); + expect(response.state).toBe("success"); + expect(enqueueJob).toHaveBeenCalledTimes(1); + expect(enqueueJob).toHaveBeenCalledWith("export_interlinear_pdf", { + languageId: language.id, + languageCode: language.code, + requestedBy: user.id, + }); +}); diff --git a/src/modules/export/actions/requestInterlinearExport.ts b/src/modules/export/actions/requestInterlinearExport.ts new file mode 100644 index 00000000..e681b034 --- /dev/null +++ b/src/modules/export/actions/requestInterlinearExport.ts @@ -0,0 +1,82 @@ +"use server"; + +import * as z from "zod"; +import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { verifySession } from "@/session"; +import { Policy } from "@/modules/access"; +import { FormState } from "@/components/Form"; +import { serverActionLogger } from "@/server-action"; +import { requestInterlinearExport as requestInterlinearExportUseCase } from "../use-cases/requestInterlinearExport"; + +const exportPolicy = new Policy({ + systemRoles: [Policy.SystemRole.Admin], + languageMember: true, +}); + +const requestSchema = z.object({ + languageCode: z.string().min(1), +}); + +type RequestInterlinearExportResult = FormState; + +export async function requestInterlinearExport( + arg1: FormState | FormData, + arg2?: FormData, +): Promise { + const formData = arg2 ?? (arg1 as FormData); + const logger = serverActionLogger("requestInterlinearExport"); + const t = await getTranslations("InterlinearExport"); + + const session = await verifySession(); + const userId = session?.user.id; + if (!userId) notFound(); + + const parsed = requestSchema.safeParse( + { + languageCode: formData.get("languageCode"), + }, + { + errorMap: (error) => { + if (error.path.toString() === "languageCode") { + return { message: t("errors.language_required") }; + } + return { message: t("errors.invalid") }; + }, + }, + ); + + if (!parsed.success) { + logger.error("request parse error"); + return { + state: "error", + validation: parsed.error.flatten().fieldErrors, + }; + } + + const authorized = await exportPolicy.authorize({ + actorId: userId, + languageCode: parsed.data.languageCode, + }); + + if (!authorized) { + logger.error("unauthorized"); + notFound(); + } + + try { + await requestInterlinearExportUseCase({ + languageCode: parsed.data.languageCode, + requestedBy: userId, + }); + + return { + state: "success", + }; + } catch (error) { + logger.error({ err: error }, "failed to request export"); + return { state: "error", error: t("errors.export_failed") }; + } +} + +export default requestInterlinearExport; diff --git a/src/modules/export/data-access/BookQueryService.ts b/src/modules/export/data-access/BookQueryService.ts new file mode 100644 index 00000000..b342e0b8 --- /dev/null +++ b/src/modules/export/data-access/BookQueryService.ts @@ -0,0 +1,41 @@ +import { query } from "@/db"; + +export interface BookRow { + id: number; + name: string; +} + +export interface BookChaptersRow { + bookId: number; + chapters: number[]; +} + +const bookQueryService = { + async findAll(): Promise { + const result = await query( + `select id, name from book order by id asc`, + [], + ); + return result.rows; + }, + + async findChapters(bookIds: number[]): Promise { + if (bookIds.length === 0) return []; + + const result = await query( + ` + select book_id as "bookId", + array_agg(distinct chapter order by chapter) as chapters + from verse + where book_id = any($1) + group by book_id + order by book_id + `, + [bookIds], + ); + + return result.rows; + }, +}; + +export default bookQueryService; diff --git a/src/modules/export/data-access/ExportJobQueryService.ts b/src/modules/export/data-access/ExportJobQueryService.ts new file mode 100644 index 00000000..9f58a477 --- /dev/null +++ b/src/modules/export/data-access/ExportJobQueryService.ts @@ -0,0 +1,80 @@ +import { query } from "@/db"; +import { JobStatus } from "@/shared/jobs/model"; +import type { + ExportInterlinearPdfJobData, + ExportInterlinearPdfJobPayload, +} from "../model"; +import { EXPORT_JOB_TYPES } from "../jobs/jobTypes"; + +export interface ExportJobRow { + id: string; + type: string; + status: JobStatus; + payload: ExportInterlinearPdfJobPayload; + data?: ExportInterlinearPdfJobData; + createdAt: Date; + updatedAt: Date; +} + +const exportJobQueryService = { + async findRecentForLanguage( + languageCode: string, + limit = 10, + ): Promise { + const result = await query( + ` + select + job.id, + job.status, + job.payload, + job.data, + job.created_at as "createdAt", + job.updated_at as "updatedAt", + job_type.name as type + from job + join job_type on job_type.id = job.type_id + where job_type.name = $1 + and job.payload->>'languageCode' = $2 + order by job.created_at desc + limit $3 + `, + [EXPORT_JOB_TYPES.EXPORT_INTERLINEAR_PDF, languageCode, limit], + ); + + return result.rows; + }, + + async findPendingForLanguage( + languageCode: string, + ): Promise { + const result = await query( + ` + select + job.id, + job.status, + job.payload, + job.data, + job.created_at as "createdAt", + job.updated_at as "updatedAt", + job_type.name as type + from job + join job_type on job_type.id = job.type_id + where job_type.name = $1 + and job.payload->>'languageCode' = $2 + and job.status in ($3, $4) + order by job.created_at desc + limit 1 + `, + [ + EXPORT_JOB_TYPES.EXPORT_INTERLINEAR_PDF, + languageCode, + JobStatus.Pending, + JobStatus.InProgress, + ], + ); + + return result.rows[0]; + }, +}; + +export default exportJobQueryService; diff --git a/src/modules/export/jobs/exportInterlinearPdfJob.ts b/src/modules/export/jobs/exportInterlinearPdfJob.ts new file mode 100644 index 00000000..5df6f2af --- /dev/null +++ b/src/modules/export/jobs/exportInterlinearPdfJob.ts @@ -0,0 +1,32 @@ +import { Job } from "@/shared/jobs/model"; +import { logger } from "@/logging"; +import { EXPORT_JOB_TYPES } from "./jobTypes"; +import type { + ExportInterlinearPdfJobData, + ExportInterlinearPdfJobPayload, +} from "../model"; + +export async function exportInterlinearPdfJob( + job: Job, +): Promise { + const jobLogger = logger.child({ jobId: job.id, jobType: job.type }); + + if (job.type !== EXPORT_JOB_TYPES.EXPORT_INTERLINEAR_PDF) { + jobLogger.error( + `received job type ${job.type}, expected ${EXPORT_JOB_TYPES.EXPORT_INTERLINEAR_PDF}`, + ); + throw new Error( + `Expected job type ${EXPORT_JOB_TYPES.EXPORT_INTERLINEAR_PDF}, but received ${job.type}`, + ); + } + + try { + jobLogger.info("Interlinear export job completed (noop)"); + return {}; + } catch (error) { + jobLogger.error({ err: error }, "Interlinear export job failed"); + throw error; + } +} + +export default exportInterlinearPdfJob; diff --git a/src/modules/export/jobs/jobTypes.ts b/src/modules/export/jobs/jobTypes.ts new file mode 100644 index 00000000..f0823794 --- /dev/null +++ b/src/modules/export/jobs/jobTypes.ts @@ -0,0 +1,3 @@ +export const EXPORT_JOB_TYPES = { + EXPORT_INTERLINEAR_PDF: "export_interlinear_pdf", +}; diff --git a/src/modules/export/model.ts b/src/modules/export/model.ts new file mode 100644 index 00000000..169289af --- /dev/null +++ b/src/modules/export/model.ts @@ -0,0 +1,12 @@ +export interface ExportInterlinearPdfJobPayload { + languageId: string; + languageCode: string; + requestedBy: string; +} + +export interface ExportInterlinearPdfJobData { + exportKey?: string; + downloadUrl?: string; + expiresAt?: string; + pages?: number; +} diff --git a/src/modules/export/react/ExportJobStatusPoller.tsx b/src/modules/export/react/ExportJobStatusPoller.tsx new file mode 100644 index 00000000..3a54567b --- /dev/null +++ b/src/modules/export/react/ExportJobStatusPoller.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import useSWR from "swr"; + +export default function ExportJobStatusPoller({ code }: { code: string }) { + const router = useRouter(); + + const { data } = useSWR( + ["export-progress", code], + async () => { + const response = await fetch("./exports/progress"); + return (await response.json()) as { done: boolean }; + }, + { + refreshInterval: 15000, + }, + ); + + if (data?.done) { + router.refresh(); + } + + return null; +} diff --git a/src/modules/export/react/InterlinearExportPanel.tsx b/src/modules/export/react/InterlinearExportPanel.tsx new file mode 100644 index 00000000..f7fc02e0 --- /dev/null +++ b/src/modules/export/react/InterlinearExportPanel.tsx @@ -0,0 +1,109 @@ +import { requestInterlinearExport } from "@/modules/export/actions/requestInterlinearExport"; +import { getTranslations } from "next-intl/server"; +import { JobStatus } from "@/shared/jobs/model"; +import Form from "@/components/Form"; +import Button from "@/components/Button"; +import { Icon } from "@/components/Icon"; +import exportJobQueryService from "../data-access/ExportJobQueryService"; +import ExportJobStatusPoller from "./ExportJobStatusPoller"; + +export default async function InterlinearExportPanel({ + languageCode, +}: { + languageCode: string; +}) { + const t = await getTranslations("InterlinearExport"); + const [jobs, pendingJob] = await Promise.all([ + exportJobQueryService.findRecentForLanguage(languageCode), + exportJobQueryService.findPendingForLanguage(languageCode), + ]); + + const statusLabels = { + [JobStatus.Pending]: t("status.labels.pending"), + [JobStatus.InProgress]: t("status.labels.in_progress"), + [JobStatus.Complete]: t("status.labels.complete"), + [JobStatus.Failed]: t("status.labels.error"), + }; + + return ( +
+
+

+ + {t("title")} +

+

{t("description")}

+
+ +
+
+ +
+ +
+
+ + {jobs.length > 0 && ( +
+
{t("status.title")}
+
+ {jobs.map((job) => { + const isComplete = job.status === JobStatus.Complete; + const isFailed = job.status === JobStatus.Failed; + const statusLabel = statusLabels[job.status] ?? job.status; + const createdAt = new Date(job.createdAt).toLocaleString(); + return ( +
+
+
+
{createdAt}
+
+ + {statusLabel} + +
+ {job.data?.downloadUrl && ( +
+ + {job.data.expiresAt && ( + + {t("status.expires")}:{" "} + {new Date(job.data.expiresAt).toLocaleString()} + + )} +
+ )} + {isFailed && ( + + {t("status.failed")} + + )} + {!isComplete && !isFailed && ( + + {t("status.generating")} + + )} +
+ ); + })} +
+
+ )} + + {pendingJob && } +
+
+ ); +} diff --git a/src/modules/export/react/LanguageExportsPage.tsx b/src/modules/export/react/LanguageExportsPage.tsx new file mode 100644 index 00000000..dd27ae5c --- /dev/null +++ b/src/modules/export/react/LanguageExportsPage.tsx @@ -0,0 +1,33 @@ +import ViewTitle from "@/components/ViewTitle"; +import { getTranslations } from "next-intl/server"; +import { Metadata, ResolvingMetadata } from "next"; +import InterlinearExportPanel from "./InterlinearExportPanel"; + +interface Props { + params: { code: string }; +} + +export async function generateMetadata( + _: any, + parent: ResolvingMetadata, +): Promise { + const t = await getTranslations("LanguageExportsPage"); + const { title } = await parent; + + return { + title: `${t("title")} | ${title?.absolute}`, + }; +} + +export default async function LanguageExportsPage({ params }: Props) { + const t = await getTranslations("LanguageExportsPage"); + + return ( +
+
+ {t("title")} + +
+
+ ); +} diff --git a/src/modules/export/route-handlers/getExportProgress.ts b/src/modules/export/route-handlers/getExportProgress.ts new file mode 100644 index 00000000..eb8e540d --- /dev/null +++ b/src/modules/export/route-handlers/getExportProgress.ts @@ -0,0 +1,36 @@ +import { NextRequest } from "next/server"; +import { verifySession } from "@/session"; +import { Policy } from "@/modules/access"; +import exportJobQueryService from "../data-access/ExportJobQueryService"; + +const exportPolicy = new Policy({ + systemRoles: [Policy.SystemRole.Admin], + languageMember: true, +}); + +export default async function handleGetExportProgress( + _request: NextRequest, + { params }: { params: { code: string } }, +) { + const session = await verifySession(); + const userId = session?.user?.id; + if (!userId) { + return new Response(null, { status: 404 }); + } + + const authorized = await exportPolicy.authorize({ + actorId: userId, + languageCode: params.code, + }); + if (!authorized) { + return new Response(null, { status: 404 }); + } + + const pending = await exportJobQueryService.findPendingForLanguage( + params.code, + ); + + return Response.json({ + done: !pending, + }); +} diff --git a/src/modules/export/use-cases/requestInterlinearExport.ts b/src/modules/export/use-cases/requestInterlinearExport.ts new file mode 100644 index 00000000..d93010f4 --- /dev/null +++ b/src/modules/export/use-cases/requestInterlinearExport.ts @@ -0,0 +1,30 @@ +import { NotFoundError } from "@/shared/errors"; +import { enqueueJob } from "@/shared/jobs/enqueueJob"; +import { resolveLanguageByCode } from "@/modules/languages"; +import { EXPORT_JOB_TYPES } from "../jobs/jobTypes"; + +export interface RequestInterlinearExportRequest { + languageCode: string; + requestedBy: string; +} + +export interface RequestInterlinearExportResult { + jobId: string; +} + +export async function requestInterlinearExport( + request: RequestInterlinearExportRequest, +): Promise { + const language = await resolveLanguageByCode(request.languageCode); + if (!language) { + throw new NotFoundError("Language"); + } + + const job = await enqueueJob(EXPORT_JOB_TYPES.EXPORT_INTERLINEAR_PDF, { + languageId: language.id, + languageCode: request.languageCode, + requestedBy: request.requestedBy, + }); + + return { jobId: job.id }; +} diff --git a/src/modules/languages/index.ts b/src/modules/languages/index.ts new file mode 100644 index 00000000..bbd18b8f --- /dev/null +++ b/src/modules/languages/index.ts @@ -0,0 +1,2 @@ +export { resolveLanguageByCode } from "./use-cases/resolveLanguageByCode"; +export type { ResolvedLanguage } from "./use-cases/resolveLanguageByCode"; diff --git a/src/modules/languages/ui/AdminLanguageLayout.tsx b/src/modules/languages/ui/AdminLanguageLayout.tsx index 980d1b2a..e6fe73d2 100644 --- a/src/modules/languages/ui/AdminLanguageLayout.tsx +++ b/src/modules/languages/ui/AdminLanguageLayout.tsx @@ -91,6 +91,19 @@ export default async function LanguageLayout({ {t("links.import")} + + + + {t("links.exports")} + + + } + /> { + const result = await query( + ` + select id, + code, + local_name as name, + text_direction as "textDirection" + from language + where code = $1 + limit 1 + `, + [code], + ); + + return result.rows[0]; +} diff --git a/src/session.ts b/src/session.ts index 6e59e893..7898e85a 100644 --- a/src/session.ts +++ b/src/session.ts @@ -3,7 +3,7 @@ import { query } from "@/db"; import { randomBytes } from "crypto"; import { cookies } from "next/headers"; -import { cache } from "react"; +import * as React from "react"; const DAY_FROM_MS = 24 * 60 * 60 * 1000; const EXPIRES_IN = @@ -75,7 +75,7 @@ interface Session { }; } -const fetchSession = cache( +const fetchSession = React.cache( async (sessionId: string): Promise => { const result = await query( ` diff --git a/src/shared/jobs/jobMap.ts b/src/shared/jobs/jobMap.ts index ad4f99f2..b77cf69d 100644 --- a/src/shared/jobs/jobMap.ts +++ b/src/shared/jobs/jobMap.ts @@ -5,6 +5,8 @@ import { REPORTING_JOB_TYPES } from "@/modules/reporting/jobs/jobTypes"; import { SNAPSHOT_JOB_TYPES } from "@/modules/snapshots/jobs/jobTypes"; import { createSnapshotJob } from "@/modules/snapshots/jobs/createSnapshotJob"; import { restoreSnapshotJob } from "@/modules/snapshots/jobs/restoreSnapshotJob"; +import { EXPORT_JOB_TYPES } from "@/modules/export/jobs/jobTypes"; +import exportInterlinearPdfJob from "@/modules/export/jobs/exportInterlinearPdfJob"; export type JobHandler = ( job: Job, @@ -26,6 +28,10 @@ const jobMap: Record> = { handler: exportAnalyticsJob, timeout: 60 * 5, // 5 minutes }, + [EXPORT_JOB_TYPES.EXPORT_INTERLINEAR_PDF]: { + handler: exportInterlinearPdfJob, + timeout: 60 * 5, // 5 minutes + }, [SNAPSHOT_JOB_TYPES.CREATE_SNAPSHOT]: { handler: createSnapshotJob, timeout: 60 * 15, // 15 minutes diff --git a/tests/vitest/testSetup.ts b/tests/vitest/testSetup.ts index 418fa1cd..701f44c9 100644 --- a/tests/vitest/testSetup.ts +++ b/tests/vitest/testSetup.ts @@ -1,10 +1,24 @@ import { webcrypto } from "node:crypto"; +import { vi } from "vitest"; import "./matchers"; // Necessary for @oslo/password to run in tests // We can remove this after we upgrade from node 18 -// @ts-ignore -globalThis.crypto = webcrypto; +if (!globalThis.crypto) { + // @ts-ignore + globalThis.crypto = webcrypto; +} process.env.ORIGIN = "globalbibletools.com"; process.env.LOG_LEVEL = "silent"; + +vi.mock("react", async () => { + const actual = await vi.importActual("react"); + return { + ...actual, + cache: + typeof actual.cache === "function" ? + actual.cache + : any>(fn: T) => fn, + }; +}); diff --git a/vitest.config.mts b/vitest.config.mts index 3678fda9..87282984 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -3,15 +3,13 @@ import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ plugins: [tsconfigPaths()], + esbuild: { + jsx: "automatic", + }, test: { include: ["**/*.{unit,test}.ts?(x)"], globalSetup: ["./tests/vitest/dbSetup.ts"], setupFiles: ["./tests/vitest/testSetup.ts"], mockReset: true, }, - resolve: { - alias: { - react: "next/dist/compiled/react/cjs/react.development.js", - }, - }, });