From 00ee5bc3915dcd6755ddae9f194e0f6af7ff4981 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 15 Apr 2026 13:41:58 +0200 Subject: [PATCH] implement advanced codemirror editor, with grammar check and translation --- Dockerfile | 6 + PROXY_SETUP.md | 26 ++ package-lock.json | 271 ++++++++++++ package.json | 12 + scripts/build-and-push-prod.sh | 8 +- .../AdvancedEventLabDialog.tsx | 392 ++++++++++++++++++ src/components/Embedded/EmbeddedNote.tsx | 52 ++- src/components/NoteList/index.tsx | 1 + .../NoteOptions/EditOrCloneEventDialog.tsx | 58 ++- src/components/PostEditor/PostContent.tsx | 85 +++- .../PostEditor/PostTextarea/index.tsx | 26 +- .../ZapStreamLiveEventEmbed/index.tsx | 6 + src/constants.ts | 13 + src/i18n/locales/de.ts | 17 + src/i18n/locales/en.ts | 17 + src/lib/advanced-event-lab-kinds.ts | 6 + src/lib/advanced-event-lab-slice.test.ts | 15 + src/lib/advanced-event-lab-slice.ts | 50 +++ src/lib/languagetool-client.ts | 54 +++ src/lib/languagetool-cm-linter.ts | 62 +++ src/lib/languagetool-language-order.test.ts | 12 + src/lib/languagetool-language-order.ts | 132 ++++++ src/lib/live-activities.ts | 9 + src/lib/read-aloud-translation-override.ts | 18 + src/lib/read-aloud.ts | 6 +- src/lib/tiptap-plaintext.test.ts | 26 ++ src/lib/tiptap.ts | 14 + src/lib/translate-client.ts | 67 +++ vite.config.ts | 10 + 29 files changed, 1452 insertions(+), 19 deletions(-) create mode 100644 src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx create mode 100644 src/lib/advanced-event-lab-kinds.ts create mode 100644 src/lib/advanced-event-lab-slice.test.ts create mode 100644 src/lib/advanced-event-lab-slice.ts create mode 100644 src/lib/languagetool-client.ts create mode 100644 src/lib/languagetool-cm-linter.ts create mode 100644 src/lib/languagetool-language-order.test.ts create mode 100644 src/lib/languagetool-language-order.ts create mode 100644 src/lib/read-aloud-translation-override.ts create mode 100644 src/lib/tiptap-plaintext.test.ts create mode 100644 src/lib/translate-client.ts diff --git a/Dockerfile b/Dockerfile index cb58dec3..5880b60a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,12 @@ ENV VITE_PROXY_SERVER=${VITE_PROXY_SERVER} ARG VITE_READ_ALOUD_TTS_URL ENV VITE_READ_ALOUD_TTS_URL=${VITE_READ_ALOUD_TTS_URL} +ARG VITE_LANGUAGE_TOOL_URL +ENV VITE_LANGUAGE_TOOL_URL=${VITE_LANGUAGE_TOOL_URL} + +ARG VITE_TRANSLATE_URL +ENV VITE_TRANSLATE_URL=${VITE_TRANSLATE_URL} + WORKDIR /app # Copy package files first diff --git a/PROXY_SETUP.md b/PROXY_SETUP.md index 026d4e9c..ca058f08 100644 --- a/PROXY_SETUP.md +++ b/PROXY_SETUP.md @@ -119,6 +119,32 @@ Expect **200** and a WAV file. **Local dev:** `npm run dev` proxies `/api/piper- Rebuild the Imwald image after changing `VITE_READ_ALOUD_TTS_URL`; `Dockerfile` passes `ARG`/`ENV` `VITE_READ_ALOUD_TTS_URL` into `npm run build`. +## LanguageTool (same-origin `/api/languagetool`) + +The advanced event lab can call **`POST /v2/check`** on a self-hosted [LanguageTool](https://github.com/languagetool-org/languagetool) server. Set **`VITE_LANGUAGE_TOOL_URL=/api/languagetool`** at build time and proxy to your LT HTTP port (default **8010**). + +Apache (before the catch-all `ProxyPass /`): + +```apache +ProxyPass /api/languagetool http://127.0.0.1:8010 +ProxyPassReverse /api/languagetool http://127.0.0.1:8010 +``` + +**Local dev:** `vite.config.ts` proxies `/api/languagetool` → `http://127.0.0.1:8010` with path rewrite so `/api/languagetool/v2/check` reaches LT’s `/v2/check`. + +If `VITE_LANGUAGE_TOOL_URL` is empty, grammar hints in the lab are disabled. + +## LibreTranslate (same-origin `/api/translate`) + +Optional **`VITE_TRANSLATE_URL=/api/translate`** for `POST /translate` (LibreTranslate-compatible). Example Apache: + +```apache +ProxyPass /api/translate http://127.0.0.1:5000 +ProxyPassReverse /api/translate http://127.0.0.1:5000 +``` + +**Local dev:** `vite.config.ts` proxies `/api/translate` → `http://127.0.0.1:5000` with path rewrite. + ## Update Proxy Server's ALLOW_ORIGIN Since users access via `https://jumble.imwald.eu`, you need to update the proxy server's `ALLOW_ORIGIN`: diff --git a/package-lock.json b/package-lock.json index 0ffc648e..91d3c2d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,23 @@ "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", + "@codemirror/autocomplete": "^6.20.1", + "@codemirror/commands": "^6.10.3", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/language": "^6.12.3", + "@codemirror/lint": "^6.9.5", + "@codemirror/search": "^6.6.0", + "@codemirror/state": "^6.6.0", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.41.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@getalby/bitcoin-connect-react": "^3.10.0", "@getalby/lightning-tools": "^6.1.0", + "@lezer/markdown": "^1.6.3", "@noble/hashes": "^1.6.1", "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-avatar": "^1.1.2", @@ -53,6 +64,7 @@ "blurhash": "^2.0.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "codemirror-asciidoc": "^2.0.1", "dataloader": "^2.2.3", "dayjs": "^1.11.13", "embla-carousel-react": "^8.6.0", @@ -1797,6 +1809,169 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", + "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz", + "integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz", + "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz", + "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.41.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.0.tgz", + "integrity": "sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", @@ -3217,6 +3392,84 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/css": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz", + "integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz", + "integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz", + "integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" + } + }, "node_modules/@lightninglabs/lnc-core": { "version": "0.3.4-alpha", "resolved": "https://registry.npmjs.org/@lightninglabs/lnc-core/-/lnc-core-0.3.4-alpha.tgz", @@ -3272,6 +3525,12 @@ "node": ">= 10.0.0" } }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, "node_modules/@noble/ciphers": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", @@ -7775,6 +8034,12 @@ "node": ">=6" } }, + "node_modules/codemirror-asciidoc": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/codemirror-asciidoc/-/codemirror-asciidoc-2.0.1.tgz", + "integrity": "sha512-h6Xhj+ZsWh/DTNE3xMfRv9edufchsVVwPED7wSGMeEdoYk/UtCZmwRGH0ZZQkr43aNVF3tWGLZJGT+cAeYgUIg==", + "license": "BSD" + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -14258,6 +14523,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", diff --git a/package.json b/package.json index c55d9f83..52d4f135 100644 --- a/package.json +++ b/package.json @@ -33,12 +33,23 @@ }, "dependencies": { "@asciidoctor/core": "^3.0.4", + "@codemirror/autocomplete": "^6.20.1", + "@codemirror/commands": "^6.10.3", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/language": "^6.12.3", + "@codemirror/lint": "^6.9.5", + "@codemirror/search": "^6.6.0", + "@codemirror/state": "^6.6.0", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.41.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@getalby/bitcoin-connect-react": "^3.10.0", "@getalby/lightning-tools": "^6.1.0", + "@lezer/markdown": "^1.6.3", "@noble/hashes": "^1.6.1", "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-avatar": "^1.1.2", @@ -76,6 +87,7 @@ "blurhash": "^2.0.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "codemirror-asciidoc": "^2.0.1", "dataloader": "^2.2.3", "dayjs": "^1.11.13", "embla-carousel-react": "^8.6.0", diff --git a/scripts/build-and-push-prod.sh b/scripts/build-and-push-prod.sh index 7d7d28b0..d2a641e5 100755 --- a/scripts/build-and-push-prod.sh +++ b/scripts/build-and-push-prod.sh @@ -8,6 +8,8 @@ # Alias: JUMBLE_PROXY_SERVER_URL (deprecated). Must match the public origin where Apache serves the app. # READ_ALOUD_TTS_URL — build-arg VITE_READ_ALOUD_TTS_URL (default /api/piper-tts). # Same-origin: Apache proxies /api/piper-tts → aitherboard (e.g. :9876). Override only if you use CORS on another host. +# LANGUAGE_TOOL_URL — build-arg VITE_LANGUAGE_TOOL_URL (default empty). Example: /api/languagetool with Apache → LanguageTool :8010. +# TRANSLATE_URL — build-arg VITE_TRANSLATE_URL (default empty). Example: /api/translate with Apache → LibreTranslate :5000. set -e REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -23,11 +25,15 @@ IMAGE_MONITOR="silberengel/imwald-jumble-nip66-monitor" # Override: IMWALD_PROXY_SERVER_URL=https://other.example ./scripts/build-and-push-prod.sh PROXY_ORIGIN="${IMWALD_PROXY_SERVER_URL:-${JUMBLE_PROXY_SERVER_URL:-https://jumble.imwald.eu}}" READ_ALOUD_TTS_URL="${READ_ALOUD_TTS_URL:-/api/piper-tts}" +LANGUAGE_TOOL_URL="${LANGUAGE_TOOL_URL:-}" +TRANSLATE_URL="${TRANSLATE_URL:-}" -echo "Building main app (version: $VERSION, VITE_PROXY_SERVER=$PROXY_ORIGIN, VITE_READ_ALOUD_TTS_URL=$READ_ALOUD_TTS_URL)" +echo "Building main app (version: $VERSION, VITE_PROXY_SERVER=$PROXY_ORIGIN, VITE_READ_ALOUD_TTS_URL=$READ_ALOUD_TTS_URL, VITE_LANGUAGE_TOOL_URL=$LANGUAGE_TOOL_URL, VITE_TRANSLATE_URL=$TRANSLATE_URL)" docker build \ --build-arg "VITE_PROXY_SERVER=$PROXY_ORIGIN" \ --build-arg "VITE_READ_ALOUD_TTS_URL=$READ_ALOUD_TTS_URL" \ + --build-arg "VITE_LANGUAGE_TOOL_URL=$LANGUAGE_TOOL_URL" \ + --build-arg "VITE_TRANSLATE_URL=$TRANSLATE_URL" \ -t "$IMAGE_APP:latest" -t "$IMAGE_APP:$VERSION" . echo "Building NIP-66 monitor (version: $VERSION)" diff --git a/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx b/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx new file mode 100644 index 00000000..d1da5dae --- /dev/null +++ b/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx @@ -0,0 +1,392 @@ +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { isLanguageToolConfigured } from '@/lib/languagetool-client' +import { languageToolLintExtension } from '@/lib/languagetool-cm-linter' +import { buildLanguageToolPreferenceList } from '@/lib/languagetool-language-order' +import { + parseLabSlice, + serializeLabSlice, + type AdvancedEventLabSlice +} from '@/lib/advanced-event-lab-slice' +import { isTranslateConfigured, translatePlainText } from '@/lib/translate-client' +import { setReadAloudTranslationForEvent } from '@/lib/read-aloud-translation-override' +import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' +import { markdown } from '@codemirror/lang-markdown' +import { json } from '@codemirror/lang-json' +import { StreamLanguage } from '@codemirror/language' +import { asciidoc } from 'codemirror-asciidoc' +import { EditorState, type Extension } from '@codemirror/state' +import { oneDark } from '@codemirror/theme-one-dark' +import { + EditorView, + keymap, + lineNumbers, + placeholder as cmPlaceholder +} from '@codemirror/view' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +export type AdvancedEventLabDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + /** Snapshot when opening; parent should memoize. */ + initial: AdvancedEventLabSlice | null + /** When false, `kind` in JSON is shown but Apply forces `initial.kind`. */ + kindEditable?: boolean + markupMode: 'markdown' | 'asciidoc' + /** `i18n.language` for LanguageTool default ordering. */ + i18nLanguage?: string + /** When set, user can store translation for read-aloud for this event id. */ + contextEventId?: string | null + onApply: (payload: AdvancedEventLabSlice) => void +} + +function useDarkModeFlag(): boolean { + const [dark, setDark] = useState(() => + typeof document !== 'undefined' + ? document.documentElement.classList.contains('dark') + : false + ) + useEffect(() => { + const el = document.documentElement + const obs = new MutationObserver(() => { + setDark(el.classList.contains('dark')) + }) + obs.observe(el, { attributes: true, attributeFilter: ['class'] }) + return () => obs.disconnect() + }, []) + return dark +} + +export default function AdvancedEventLabDialog({ + open, + onOpenChange, + initial, + kindEditable = true, + markupMode, + i18nLanguage, + contextEventId, + onApply +}: AdvancedEventLabDialogProps) { + const { t, i18n } = useTranslation() + const dark = useDarkModeFlag() + const markupHost = useRef(null) + const jsonHost = useRef(null) + const markupView = useRef(null) + const jsonView = useRef(null) + const sliceRef = useRef(null) + const syncing = useRef(false) + + const ltList = useMemo( + () => buildLanguageToolPreferenceList(i18nLanguage ?? i18n.language), + [i18nLanguage, i18n.language] + ) + const [ltLang, setLtLang] = useState(() => ltList[0] ?? 'en-US') + const [jsonError, setJsonError] = useState(null) + const [translateTarget, setTranslateTarget] = useState('en') + + useEffect(() => { + if (open) { + setLtLang(ltList[0] ?? 'en-US') + } + }, [open, ltList]) + + const destroyEditors = useCallback(() => { + markupView.current?.destroy() + jsonView.current?.destroy() + markupView.current = null + jsonView.current = null + }, []) + + useEffect(() => { + if (!open || !initial) { + destroyEditors() + return + } + const mkEl = markupHost.current + const jsEl = jsonHost.current + if (!mkEl || !jsEl) return + + destroyEditors() + + const baseSlice: AdvancedEventLabSlice = { + kind: initial.kind, + content: initial.content, + tags: initial.tags.map((row) => [...row]) + } + sliceRef.current = baseSlice + + const markupLang: Extension = + markupMode === 'asciidoc' ? StreamLanguage.define(asciidoc) : markdown() + + const mkExtensions: Extension[] = [ + history(), + keymap.of([...defaultKeymap, ...historyKeymap]), + lineNumbers(), + cmPlaceholder(t('Advanced lab markup placeholder')), + markupLang, + EditorView.theme({ + '&': { maxHeight: '100%' }, + '.cm-scroller': { overflow: 'auto' }, + '.cm-content': { minHeight: '220px', fontFamily: 'var(--font-mono, ui-monospace, monospace)' } + }), + EditorView.updateListener.of((update) => { + if (!update.docChanged || syncing.current) return + const content = update.state.doc.toString() + const s = sliceRef.current + if (!s) return + s.content = content + const jv = jsonView.current + if (!jv) return + const nextJson = serializeLabSlice({ + kind: kindEditable ? s.kind : (initial?.kind ?? s.kind), + content: s.content, + tags: s.tags + }) + if (jv.state.doc.toString() === nextJson) return + syncing.current = true + jv.dispatch({ + changes: { from: 0, to: jv.state.doc.length, insert: nextJson }, + selection: { anchor: 0 } + }) + syncing.current = false + }) + ] + if (isLanguageToolConfigured()) { + mkExtensions.push(languageToolLintExtension(ltLang, 450)) + } + if (dark) mkExtensions.push(oneDark) + + const jsonExtensions: Extension[] = [ + history(), + keymap.of([...defaultKeymap, ...historyKeymap]), + lineNumbers(), + json(), + cmPlaceholder(t('Advanced lab json placeholder')), + EditorView.theme({ + '&': { maxHeight: '100%' }, + '.cm-scroller': { overflow: 'auto' }, + '.cm-content': { minHeight: '220px', fontFamily: 'var(--font-mono, ui-monospace, monospace)' } + }), + EditorView.updateListener.of((update) => { + if (!update.docChanged || syncing.current) return + const parsed = parseLabSlice(update.state.doc.toString()) + if (!parsed.ok) { + setJsonError(parsed.error) + return + } + setJsonError(null) + const fixedKind = kindEditable ? parsed.value.kind : (initial.kind ?? parsed.value.kind) + const next: AdvancedEventLabSlice = { + kind: fixedKind, + content: parsed.value.content, + tags: parsed.value.tags + } + sliceRef.current = next + const mv = markupView.current + if (mv && mv.state.doc.toString() !== next.content) { + syncing.current = true + mv.dispatch({ + changes: { from: 0, to: mv.state.doc.length, insert: next.content }, + selection: { anchor: Math.min(mv.state.selection.main.anchor, next.content.length) } + }) + syncing.current = false + } + }) + ] + if (dark) jsonExtensions.push(oneDark) + + const mkState = EditorState.create({ + doc: baseSlice.content, + extensions: mkExtensions + }) + const jsState = EditorState.create({ + doc: serializeLabSlice({ + kind: baseSlice.kind, + content: baseSlice.content, + tags: baseSlice.tags + }), + extensions: jsonExtensions + }) + + markupView.current = new EditorView({ state: mkState, parent: mkEl }) + jsonView.current = new EditorView({ state: jsState, parent: jsEl }) + + return destroyEditors + }, [ + open, + initial, + markupMode, + ltLang, + kindEditable, + dark, + destroyEditors, + t + ]) + + const handleApply = () => { + const raw = jsonView.current?.state.doc.toString() ?? '' + const parsed = parseLabSlice(raw) + if (!parsed.ok) { + toast.error(parsed.error) + return + } + const kind = kindEditable ? parsed.value.kind : (initial?.kind ?? parsed.value.kind) + const payload: AdvancedEventLabSlice = { + kind, + content: parsed.value.content, + tags: parsed.value.tags + } + onApply(payload) + onOpenChange(false) + } + + const handleTranslate = async () => { + if (!isTranslateConfigured()) { + toast.message(t('Advanced lab translate not configured')) + return + } + const text = markupView.current?.state.doc.toString() ?? sliceRef.current?.content ?? '' + if (!text.trim()) return + try { + const out = await translatePlainText(text, translateTarget.trim() || 'en') + if (!markupView.current) return + syncing.current = true + markupView.current.dispatch({ + changes: { from: 0, to: markupView.current.state.doc.length, insert: out } + }) + syncing.current = false + const s = sliceRef.current + if (s) { + s.content = out + const jv = jsonView.current + if (jv) { + const nextJson = serializeLabSlice({ + kind: kindEditable ? s.kind : (initial?.kind ?? s.kind), + content: out, + tags: s.tags + }) + syncing.current = true + jv.dispatch({ changes: { from: 0, to: jv.state.doc.length, insert: nextJson } }) + syncing.current = false + } + } + toast.success(t('Advanced lab translate done')) + } catch (e) { + toast.error(e instanceof Error ? e.message : String(e)) + } + } + + const handleReadAloudBuffer = () => { + if (!contextEventId) return + const text = markupView.current?.state.doc.toString().trim() ?? '' + if (!text) return + setReadAloudTranslationForEvent(contextEventId, text) + toast.success(t('Advanced lab read aloud buffer set')) + } + + return ( + + + + {t('Advanced event lab')} + + {t('Advanced lab hint')} + + + +
+
+ {isLanguageToolConfigured() ? ( +
+ + +
+ ) : null} + {isTranslateConfigured() ? ( +
+
+ + setTranslateTarget(e.target.value)} + placeholder="en" + /> +
+ +
+ ) : null} + {contextEventId && isTranslateConfigured() ? ( + + ) : null} +
+ {jsonError ? ( +

+ {jsonError} +

+ ) : null} +
+ +
+
+ {t('Advanced lab markup')} +
+
+
+ {t('Advanced lab tags JSON')} +
+
+
+ + + + + + +
+ ) +} diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index f28b2b59..43a5d592 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -1,6 +1,8 @@ import { Skeleton } from '@/components/ui/skeleton' +import ExternalLink from '@/components/ExternalLink' import { FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS, ExtendedKind } from '@/constants' import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' +import { LIVE_ACTIVITY_KINDS, liveActivityKindsEnabledInPicker } from '@/lib/live-activities' import { isRenderableNoteKind } from '@/lib/note-renderable-kinds' import { useFetchEvent } from '@/hooks' import { normalizeUrl } from '@/lib/url' @@ -8,6 +10,7 @@ import { cn } from '@/lib/utils' import client from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' import { useFavoriteRelays } from '@/providers/favorite-relays-context' +import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import { useTranslation } from 'react-i18next' import { useEffect, useMemo, useState } from 'react' import { Event, nip19 } from 'nostr-tools' @@ -162,6 +165,24 @@ function EmbeddedNoteInvalid({ ) } +function SuppressedLiveStreamEmbed({ noteId, className }: { noteId: string; className?: string }) { + const { t } = useTranslation() + const trimmed = noteId.trim() + const njump = `https://njump.me/${trimmed}` + + return ( +
e.stopPropagation()} + data-live-embed-suppressed + > +

{t('liveStreamEmbedSuppressed')}

+ + +
+ ) +} + function EmbeddedNoteContent({ noteId, className, @@ -173,7 +194,22 @@ function EmbeddedNoteContent({ containingEvent?: Event showFull?: boolean }) { - const { event, isFetching } = useFetchEvent(noteId) + const { showKinds, feedKindFilterBypass } = useKindFilterOrDefaults() + const allowLiveEmbeds = liveActivityKindsEnabledInPicker(showKinds, feedKindFilterBypass) + + const naddrTargetsLiveActivityOnly = useMemo(() => { + try { + const dec = nip19.decode(noteId.trim()) + if (dec.type !== 'naddr') return false + return LIVE_ACTIVITY_KINDS.includes(dec.data.kind as (typeof LIVE_ACTIVITY_KINDS)[number]) + } catch { + return false + } + }, [noteId]) + + const skipLiveActivityFetch = naddrTargetsLiveActivityOnly && !allowLiveEmbeds + + const { event, isFetching } = useFetchEvent(skipLiveActivityFetch ? undefined : noteId) const [retryEvent, setRetryEvent] = useState(undefined) const [isRetrying, setIsRetrying] = useState(false) const [retryCount, setRetryCount] = useState(0) @@ -181,6 +217,7 @@ function EmbeddedNoteContent({ // If the first fetch fails, try a force retry (max 3 attempts) useEffect(() => { + if (skipLiveActivityFetch) return if (!isFetching && !event && !isRetrying && retryCount < maxRetries) { setIsRetrying(true) setRetryCount(prev => prev + 1) @@ -203,7 +240,11 @@ function EmbeddedNoteContent({ setIsRetrying(false) }) } - }, [isFetching, event, noteId, isRetrying, retryCount]) + }, [isFetching, event, noteId, isRetrying, retryCount, skipLiveActivityFetch]) + + if (skipLiveActivityFetch) { + return + } const finalEvent = event || retryEvent const finalIsFetching = isFetching || (isRetrying && retryCount <= maxRetries) @@ -216,6 +257,13 @@ function EmbeddedNoteContent({ return } + if ( + !allowLiveEmbeds && + LIVE_ACTIVITY_KINDS.includes(finalEvent.kind as (typeof LIVE_ACTIVITY_KINDS)[number]) + ) { + return + } + // Check if this event has bookstr tags (at least "book" tag) const bookMetadata = extractBookMetadata(finalEvent) const hasBookstrTags = !!bookMetadata.book diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 028f1d71..475d2d3f 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -1002,6 +1002,7 @@ const NoteList = forwardRef( showCount, shouldHideEvent, showKinds, + effectiveShowKinds, showKind1OPs, showKind1Replies, showKind1111, diff --git a/src/components/NoteOptions/EditOrCloneEventDialog.tsx b/src/components/NoteOptions/EditOrCloneEventDialog.tsx index 036cbbaa..0da58fa2 100644 --- a/src/components/NoteOptions/EditOrCloneEventDialog.tsx +++ b/src/components/NoteOptions/EditOrCloneEventDialog.tsx @@ -42,10 +42,13 @@ import { useNostr } from '@/providers/NostrProvider' import storage from '@/services/local-storage.service' import type { TDraftEvent } from '@/types' import dayjs from 'dayjs' -import { AlertTriangle, Plus, Trash2 } from 'lucide-react' +import { AlertTriangle, Code2, Plus, Trash2 } from 'lucide-react' import { Event, kinds } from 'nostr-tools' import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' +import AdvancedEventLabDialog from '@/components/AdvancedEventLab/AdvancedEventLabDialog' +import type { AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice' +import { isAsciidocMarkupKind } from '@/lib/advanced-event-lab-kinds' function normalizeTagRow(row: string[]): string[] | null { const trimmed = row.map((c) => c.trim()) @@ -128,13 +131,15 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp const isCreate = mode === 'create' const sourceEvent = !isCreate ? props.sourceEvent : null - const { t } = useTranslation() + const { t, i18n } = useTranslation() const { pubkey, publish, checkLogin } = useNostr() const [content, setContent] = useState(() => sourceEvent?.content ?? '') const [createKindInput, setCreateKindInput] = useState('1') const [tagRows, setTagRows] = useState([['', '']]) const [activeTab, setActiveTab] = useState('edit') const [publishing, setPublishing] = useState(false) + const [advancedLabOpen, setAdvancedLabOpen] = useState(false) + const [advancedLabInitial, setAdvancedLabInitial] = useState(null) const prevOpenRef = useRef(false) const parsedCreateKind = useMemo( @@ -346,7 +351,21 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp ? t('Clone or fork this event') : t('Create custom event') + const openAdvancedLab = useCallback(() => { + if (isCreate && parsedCreateKind === null) return + const k = isCreate ? parsedCreateKind! : sourceEvent!.kind + setAdvancedLabInitial({ + kind: k, + content, + tags: normalizedTags.map((row) => [...row]) + }) + setAdvancedLabOpen(true) + }, [isCreate, parsedCreateKind, sourceEvent, content, normalizedTags]) + + const labKind = isCreate ? (parsedCreateKind ?? 0) : sourceEvent?.kind ?? 0 + return ( + <> @@ -360,10 +379,21 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
- + {t('Edit')} {t('Preview')} {t('Json')} + @@ -546,5 +576,27 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
+ { + setAdvancedLabOpen(o) + if (!o) setAdvancedLabInitial(null) + }} + initial={advancedLabInitial} + kindEditable={isCreate} + markupMode={isAsciidocMarkupKind(labKind) ? 'asciidoc' : 'markdown'} + i18nLanguage={i18n.language} + contextEventId={!isCreate && sourceEvent ? sourceEvent.id : null} + onApply={(payload) => { + setContent(payload.content) + setTagRows(payload.tags.length > 0 ? payload.tags.map((r) => [...r]) : [['', '']]) + if (isCreate) { + setCreateKindInput(String(payload.kind)) + } + setAdvancedLabOpen(false) + setAdvancedLabInitial(null) + }} + /> + ) } diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 240f9ebf..86080dc5 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -35,6 +35,7 @@ import { createCitationHardcopyDraftEvent, createCitationPromptDraftEvent, applyImwaldAttributionTags, + collectUploadImetaTagsForContentUrls, mergeUploadImetaTagsInto } from '@/lib/draft-event' import { ExtendedKind, MAX_PUBLISH_RELAYS } from '@/constants' @@ -71,7 +72,8 @@ import { Music, Video, Film, - Laugh + Laugh, + Code2 } from 'lucide-react' import { fileLooksLikeUploadableMedia } from '@/lib/compress-upload-media' import { nip94PairsToImetaTag } from '@/lib/upload-nip94-imeta' @@ -119,6 +121,9 @@ import { MentionAndEventToolbarButtons } from './PostTextarea/Mention/MentionAnd import Uploader from './Uploader' import HighlightEditor, { HighlightData } from './HighlightEditor' import EditOrCloneEventDialog from '../NoteOptions/EditOrCloneEventDialog' +import AdvancedEventLabDialog from '@/components/AdvancedEventLab/AdvancedEventLabDialog' +import type { AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice' +import { isAsciidocMarkupKind } from '@/lib/advanced-event-lab-kinds' export default function PostContent({ defaultContent = '', @@ -142,7 +147,7 @@ export default function PostContent({ /** Optional hot/discussion topics (e.g. from Discussions spell) for the thread composer. */ discussionDynamicTopics?: TDiscussionDynamicTopics | null }) { - const { t } = useTranslation() + const { t, i18n } = useTranslation() const { pubkey, publish, checkLogin } = useNostr() const { userGroups } = useGroupList() const { feedInfo } = useFeed() @@ -203,6 +208,9 @@ export default function PostContent({ ) const [text, setText] = useState('') const textareaRef = useRef(null) + const labTagOverrideRef = useRef(null) + const [advancedLabOpen, setAdvancedLabOpen] = useState(false) + const [advancedLabInitial, setAdvancedLabInitial] = useState(null) const mediaUploaderBtnRef = useRef(null) const [posting, setPosting] = useState(false) const [uploadProgresses, setUploadProgresses] = useState< @@ -1074,6 +1082,14 @@ export default function PostContent({ t ]) + const applyLabTagOverrideToDraft = useCallback((draft: TDraftEvent): TDraftEvent => { + if (!labTagOverrideRef.current) return draft + const tags = labTagOverrideRef.current.map((r) => [...r]) + labTagOverrideRef.current = null + mergeUploadImetaTagsInto(tags, collectUploadImetaTagsForContentUrls(draft.content)) + return { ...draft, tags } + }, []) + // Function to generate draft event JSON for preview const getDraftEventJson = useCallback(async (): Promise => { if (!pubkey) { @@ -1083,13 +1099,35 @@ export default function PostContent({ try { // Clean tracking parameters from URLs in the post content const cleanedText = rewritePlainTextHttpUrls(text) - - const draftEvent = await createDraftEvent(cleanedText) + + let draftEvent = await createDraftEvent(cleanedText) + draftEvent = applyLabTagOverrideToDraft(draftEvent) return JSON.stringify(applyImwaldAttributionTags(draftEvent, { addClientTag }), null, 2) } catch (error) { return JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2) } - }, [text, pubkey, isDiscussionThread, createDraftEvent, addClientTag]) + }, [text, pubkey, isDiscussionThread, createDraftEvent, addClientTag, applyLabTagOverrideToDraft]) + + const handleOpenAdvancedLab = useCallback(async () => { + await checkLogin(async () => { + if (!pubkey) { + toast.error(t('Log in to publish')) + return + } + try { + const cleanedText = rewritePlainTextHttpUrls(text) + const d = await createDraftEvent(cleanedText) + setAdvancedLabInitial({ + kind: d.kind, + content: d.content, + tags: (d.tags ?? []).map((row: string[]) => [...row]) + }) + setAdvancedLabOpen(true) + } catch (e) { + toast.error(e instanceof Error ? e.message : String(e)) + } + }) + }, [checkLogin, pubkey, text, createDraftEvent, t]) const post = async (e?: React.MouseEvent) => { e?.stopPropagation() @@ -1162,6 +1200,7 @@ export default function PostContent({ // Create draft event using shared function draftEvent = await createDraftEvent(cleanedText) + draftEvent = applyLabTagOverrideToDraft(draftEvent) const publishSuccessMessage = parentEvent ? t('Reply published') @@ -2855,8 +2894,7 @@ export default function PostContent({ addClientTag={addClientTag} mediaImetaTags={mediaImetaTags} mediaUrl={mediaUrl} - headerActions={ - !parentEvent ? (() => { + headerActions={(() => { const ActiveIcon = isLongFormArticle ? FileText : isWikiArticle ? FileText : @@ -2886,6 +2924,19 @@ export default function PostContent({ t('Short Note') return (
+ + {!parentEvent ? ( + <>
) - })() : undefined + })() } /> {isDiscussionThread && !parentEvent && ( @@ -3482,6 +3535,22 @@ export default function PostContent({ + { + setAdvancedLabOpen(o) + if (!o) setAdvancedLabInitial(null) + }} + initial={advancedLabInitial} + kindEditable={false} + markupMode={isAsciidocMarkupKind(getDeterminedKind) ? 'asciidoc' : 'markdown'} + i18nLanguage={i18n.language} + contextEventId={parentEvent?.id ?? null} + onApply={(payload) => { + labTagOverrideRef.current = payload.tags.map((r) => [...r]) + textareaRef.current?.setDocumentFromPlainText(payload.content) + }} + /> void clear: () => void getText: () => string + /** Replace editor from plain `content` (e.g. advanced lab). Syncs TipTap JSON cache and parent `text`. */ + setDocumentFromPlainText: (plain: string) => void } const PostTextarea = forwardRef< @@ -121,6 +123,7 @@ const PostTextarea = forwardRef< const [isLoadingJson, setIsLoadingJson] = useState(false) /** Bumps when preview tab is shown or a new JSON fetch starts; completions only apply if seq still matches. */ const jsonPanelFetchSeq = useRef(0) + const editorRef = useRef(null) const kindDescription = useMemo(() => getKindDescription(kind), [kind]) @@ -237,10 +240,13 @@ const PostTextarea = forwardRef< } }) + editorRef.current = editor + useImperativeHandle(ref, () => ({ appendText: (text: string, addNewline = false) => { - if (editor) { - let chain = editor + const ed = editorRef.current + if (ed) { + let chain = ed .chain() .focus() .command(({ tr, dispatch }) => { @@ -260,11 +266,13 @@ const PostTextarea = forwardRef< } }, insertText: (text: string) => { + const editor = editorRef.current if (editor) { editor.chain().focus().insertContent(text).run() } }, insertEmoji: (emoji: string | TEmoji) => { + const editor = editorRef.current if (editor) { if (typeof emoji === 'string') { editor.chain().insertContent(emoji).run() @@ -277,6 +285,7 @@ const PostTextarea = forwardRef< } }, clear: () => { + const editor = editorRef.current if (editor) { // Clear the editor content and reset to empty document editor.chain().clearContent().run() @@ -286,10 +295,19 @@ const PostTextarea = forwardRef< } }, getText: () => { + const editor = editorRef.current if (editor) { return editor.getText() } return '' + }, + setDocumentFromPlainText: (plain: string) => { + const editor = editorRef.current + if (!editor) return + const json = plainTextToTipTapDoc(plain) + editor.chain().setContent(json).run() + postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, editor.getJSON()) + setText(parseEditorJsonToText(editor.getJSON())) } })) diff --git a/src/components/ZapStreamLiveEventEmbed/index.tsx b/src/components/ZapStreamLiveEventEmbed/index.tsx index ced00ba6..133c1c4b 100644 --- a/src/components/ZapStreamLiveEventEmbed/index.tsx +++ b/src/components/ZapStreamLiveEventEmbed/index.tsx @@ -1,7 +1,9 @@ import { EmbeddedNote } from '@/components/Embedded/EmbeddedNote' import ExternalLink from '@/components/ExternalLink' +import { liveActivityKindsEnabledInPicker } from '@/lib/live-activities' import { naddrFromZapStreamWatchUrl } from '@/lib/zap-stream-url' import { cn } from '@/lib/utils' +import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import type { Event } from 'nostr-tools' /** zap.stream `/naddr1…` → fetch kind 30311 and render as embedded note (LiveEvent), not a full-site iframe. */ @@ -16,6 +18,10 @@ export default function ZapStreamLiveEventEmbed({ containingEvent?: Event showFull?: boolean }) { + const { showKinds, feedKindFilterBypass } = useKindFilterOrDefaults() + if (!liveActivityKindsEnabledInPicker(showKinds, feedKindFilterBypass)) { + return + } const naddr = naddrFromZapStreamWatchUrl(url) if (!naddr) { return diff --git a/src/constants.ts b/src/constants.ts index 737cd6fd..fae08399 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -27,6 +27,19 @@ export const GITREPUBLIC_WEB_BASE_URL = ( export const READ_ALOUD_TTS_URL = (import.meta.env.VITE_READ_ALOUD_TTS_URL as string | undefined)?.trim() || '' +/** + * Self-hosted LanguageTool HTTP API (same-origin proxy recommended; path is base URL without `/v2/check`). + * Example: `/api/languagetool` proxied to `http://127.0.0.1:8010`. Empty disables grammar hints in the advanced lab. + */ +export const LANGUAGE_TOOL_URL = + (import.meta.env.VITE_LANGUAGE_TOOL_URL as string | undefined)?.trim() || '' + +/** + * LibreTranslate-compatible `POST /translate` base (no trailing slash). Empty disables translate actions in the lab. + */ +export const TRANSLATE_URL = + (import.meta.env.VITE_TRANSLATE_URL as string | undefined)?.trim() || '' + /** HiveTalk (WebRTC video call) base URL; override with VITE_HIVETALK_BASE_URL for self-hosted instances. */ export const HIVETALK_BASE_URL = (import.meta.env.VITE_HIVETALK_BASE_URL as string | undefined) ?? 'https://vanilla.hivetalk.org' diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 22ab24c9..ee3639ee 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -500,6 +500,8 @@ export default { 'Per URL geöffnet — nicht aus deiner RSS-Liste. Der Nostr-Thread hängt weiter an diesem Link.', 'Open in browser': 'Im Browser öffnen', 'liveEvent.zapStreamPlayer': 'Livestream (zap.stream)', + 'liveStreamEmbedSuppressed': + 'Der eingebettete Livestream ist ausgeblendet, weil dein Kind-Filter NIP-53-Streams ausschließt. Unten per njump oder anderem Client öffnen.', 'liveEvent.hlsPlaybackUnavailable': 'Wiedergabe hier fehlgeschlagen (Stream offline, beendet oder blockiert). Die gehostete Watch-Seite kannst du unten trotzdem öffnen.', 'liveEvent.hideFromCarousel': 'Im Karussell ausblenden', @@ -952,6 +954,21 @@ export default { 'See all events hint': 'Feed-Anfragen ohne Kind-Filter; alle Event-Arten werden angezeigt (Relay-Limits und andere Regeln gelten weiter). Zum Testen neuer Event-Kinds.', 'Use filter hint': 'Nur unten ausgewählte Kinds werden angefragt und angezeigt.', + 'Advanced event lab': 'Erweiterter Editor', + 'Advanced lab hint': + 'Markup und JSON (kind, content, tags) bearbeiten. id, pubkey, sig und created_at werden beim Veröffentlichen gesetzt.', + 'Advanced lab markup': 'Markup', + 'Advanced lab markup placeholder': 'Notiztext (Markdown oder AsciiDoc)', + 'Advanced lab tags JSON': 'Kind, Inhalt und Tags (JSON)', + 'Advanced lab json placeholder': '{ "kind": 1, "content": "…", "tags": [] }', + 'Advanced lab grammar language': 'Sprache für Grammatikprüfung', + 'Advanced lab translate': 'Text übersetzen', + 'Advanced lab translation target': 'Zielsprache', + 'Advanced lab translate not configured': 'Übersetzungs-URL ist nicht gesetzt (VITE_TRANSLATE_URL).', + 'Advanced lab translate done': 'Übersetzung wurde in den Editor eingefügt.', + 'Advanced lab use translation read aloud': 'Text für Vorlesen verwenden (diese Notiz)', + 'Advanced lab read aloud buffer set': + 'Das nächste Vorlesen dieser Notiz nutzt den aktuellen Text (ggf. nach Übersetzung).', Apply: 'Anwenden', Reset: 'Zurücksetzen', 'Share something on this Relay': 'Teile etwas auf diesem Relay', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 9b466530..18bd1e4b 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -497,6 +497,8 @@ export default { 'Opened by URL — not from your RSS list. Nostr thread is still tied to this link.', 'Open in browser': 'Open in browser', 'liveEvent.zapStreamPlayer': 'Live stream (zap.stream)', + 'liveStreamEmbedSuppressed': + 'Inline live stream is hidden because your kind filter excludes NIP-53 streams. Open via njump or another client below.', 'liveEvent.hlsPlaybackUnavailable': 'Inline playback failed (the stream may be offline, ended, or blocked). You can still open the hosted watch page below.', 'liveEvent.hideFromCarousel': 'Hide from carousel', @@ -953,6 +955,21 @@ export default { 'See all events hint': 'Feed requests omit kind filters and every kind is shown (still subject to relay limits and other feed rules). For testing new event kinds.', 'Use filter hint': 'Only the kinds you select below are requested and shown.', + 'Advanced event lab': 'Advanced editor', + 'Advanced lab hint': + 'Edit markup and JSON (kind, content, tags). id, pubkey, sig, and created_at are assigned when you publish.', + 'Advanced lab markup': 'Markup', + 'Advanced lab markup placeholder': 'Note body (Markdown or AsciiDoc)', + 'Advanced lab tags JSON': 'Kind, content, and tags (JSON)', + 'Advanced lab json placeholder': '{ "kind": 1, "content": "…", "tags": [] }', + 'Advanced lab grammar language': 'Grammar check language', + 'Advanced lab translate': 'Translate body', + 'Advanced lab translation target': 'Target language', + 'Advanced lab translate not configured': 'Translation URL is not set (VITE_TRANSLATE_URL).', + 'Advanced lab translate done': 'Translation inserted into the editor.', + 'Advanced lab use translation read aloud': 'Use body for read-aloud (this note)', + 'Advanced lab read aloud buffer set': + 'The next read-aloud for this note will use the current body text (translated if you translated first).', Apply: 'Apply', Reset: 'Reset', 'Share something on this Relay': 'Share something on this Relay', diff --git a/src/lib/advanced-event-lab-kinds.ts b/src/lib/advanced-event-lab-kinds.ts new file mode 100644 index 00000000..7c3c531c --- /dev/null +++ b/src/lib/advanced-event-lab-kinds.ts @@ -0,0 +1,6 @@ +import { ExtendedKind } from '@/constants' + +/** Kinds whose body is AsciiDoc in Imwald (wiki article, publication content). */ +export function isAsciidocMarkupKind(kind: number): boolean { + return kind === ExtendedKind.WIKI_ARTICLE || kind === ExtendedKind.PUBLICATION_CONTENT +} diff --git a/src/lib/advanced-event-lab-slice.test.ts b/src/lib/advanced-event-lab-slice.test.ts new file mode 100644 index 00000000..09f7af24 --- /dev/null +++ b/src/lib/advanced-event-lab-slice.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { parseLabSlice, serializeLabSlice } from '@/lib/advanced-event-lab-slice' + +describe('parseLabSlice', () => { + it('round-trips', () => { + const v = { kind: 1, content: 'hello', tags: [['e', 'abc'], ['p', 'def']] } + const s = serializeLabSlice(v) + const p = parseLabSlice(s) + expect(p).toEqual({ ok: true, value: v }) + }) + + it('rejects bad kind', () => { + expect(parseLabSlice('{"kind":"x","content":"","tags":[]}').ok).toBe(false) + }) +}) diff --git a/src/lib/advanced-event-lab-slice.ts b/src/lib/advanced-event-lab-slice.ts new file mode 100644 index 00000000..74583ac0 --- /dev/null +++ b/src/lib/advanced-event-lab-slice.ts @@ -0,0 +1,50 @@ +export type AdvancedEventLabSlice = { + kind: number + content: string + tags: string[][] +} + +export function serializeLabSlice(slice: AdvancedEventLabSlice): string { + return JSON.stringify( + { + kind: slice.kind, + content: slice.content, + tags: slice.tags + }, + null, + 2 + ) +} + +export function parseLabSlice( + raw: string +): { ok: true; value: AdvancedEventLabSlice } | { ok: false; error: string } { + let o: unknown + try { + o = JSON.parse(raw) + } catch { + return { ok: false, error: 'Invalid JSON' } + } + if (!o || typeof o !== 'object' || Array.isArray(o)) { + return { ok: false, error: 'Root must be an object' } + } + const rec = o as Record + if (typeof rec.kind !== 'number' || !Number.isFinite(rec.kind) || !Number.isInteger(rec.kind)) { + return { ok: false, error: '`kind` must be an integer' } + } + if (typeof rec.content !== 'string') { + return { ok: false, error: '`content` must be a string' } + } + if (!Array.isArray(rec.tags)) { + return { ok: false, error: '`tags` must be an array' } + } + const tags: string[][] = [] + for (let i = 0; i < rec.tags.length; i++) { + const row = rec.tags[i] + if (!Array.isArray(row) || !row.every((c) => typeof c === 'string')) { + return { ok: false, error: `tags[${i}] must be an array of strings` } + } + tags.push([...row]) + } + return { ok: true, value: { kind: rec.kind, content: rec.content, tags } } +} diff --git a/src/lib/languagetool-client.ts b/src/lib/languagetool-client.ts new file mode 100644 index 00000000..c215a43e --- /dev/null +++ b/src/lib/languagetool-client.ts @@ -0,0 +1,54 @@ +import { LANGUAGE_TOOL_URL } from '@/constants' +import logger from '@/lib/logger' + +export type LanguageToolMatch = { + offset: number + length: number + message: string + replacements?: Array<{ value: string }> + rule?: { id?: string; description?: string } +} + +export type LanguageToolCheckResponse = { + matches?: LanguageToolMatch[] + software?: { name?: string; version?: string } + language?: { name?: string; code?: string } +} + +function checkUrl(): string | null { + const base = LANGUAGE_TOOL_URL.trim().replace(/\/$/u, '') + if (!base) return null + return `${base}/v2/check` +} + +export async function languageToolCheck( + text: string, + language: string, + signal?: AbortSignal +): Promise { + const url = checkUrl() + if (!url) { + return { matches: [] } + } + const body = new URLSearchParams() + body.set('text', text) + body.set('language', language) + body.set('enabledOnly', 'false') + + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + signal + }) + if (!res.ok) { + const errText = await res.text().catch(() => '') + logger.warn('[LanguageTool] HTTP error', { status: res.status, errText: errText.slice(0, 200) }) + throw new Error(`LanguageTool: ${res.status}`) + } + return (await res.json()) as LanguageToolCheckResponse +} + +export function isLanguageToolConfigured(): boolean { + return Boolean(checkUrl()) +} diff --git a/src/lib/languagetool-cm-linter.ts b/src/lib/languagetool-cm-linter.ts new file mode 100644 index 00000000..ecbef212 --- /dev/null +++ b/src/lib/languagetool-cm-linter.ts @@ -0,0 +1,62 @@ +import { linter, type Diagnostic } from '@codemirror/lint' +import type { Extension } from '@codemirror/state' +import { languageToolCheck, type LanguageToolMatch } from '@/lib/languagetool-client' + +function matchToDiagnostic(docLen: number, m: LanguageToolMatch): Diagnostic | null { + const from = Math.max(0, Math.min(m.offset, docLen)) + const to = Math.max(from, Math.min(m.offset + m.length, docLen)) + if (to <= from) return null + const fix = m.replacements?.[0]?.value + return { + from, + to, + severity: 'info', + message: m.message + (m.rule?.id ? ` (${m.rule.id})` : ''), + actions: fix + ? [ + { + name: 'Apply', + apply(view) { + view.dispatch({ changes: { from, to, insert: fix } }) + } + } + ] + : undefined + } +} + +/** + * Async grammar/style lint for CodeMirror using LanguageTool `/v2/check`. + */ +export function languageToolLintExtension( + language: string, + debounceMs: number +): Extension { + return linter((view) => { + return new Promise((resolve) => { + const text = view.state.doc.toString() + if (text.length < 3) { + resolve([]) + return + } + const seq = ++requestSeq + window.setTimeout(() => { + if (seq !== requestSeq) return + void languageToolCheck(text, language) + .then((res) => { + if (seq !== requestSeq) return + const docLen = view.state.doc.length + const out: Diagnostic[] = [] + for (const m of res.matches ?? []) { + const d = matchToDiagnostic(docLen, m) + if (d) out.push(d) + } + resolve(out) + }) + .catch(() => resolve([])) + }, debounceMs) + }) + }) +} + +let requestSeq = 0 diff --git a/src/lib/languagetool-language-order.test.ts b/src/lib/languagetool-language-order.test.ts new file mode 100644 index 00000000..837dff8a --- /dev/null +++ b/src/lib/languagetool-language-order.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { buildLanguageToolPreferenceList } from '@/lib/languagetool-language-order' + +describe('buildLanguageToolPreferenceList', () => { + it('puts client language first then en-US then de-DE', () => { + const list = buildLanguageToolPreferenceList('de') + expect(list[0]).toBe('de-DE') + expect(list[1]).toBe('en-US') + expect(list.includes('de-DE')).toBe(true) + expect(list.indexOf('en-US')).toBe(1) + }) +}) diff --git a/src/lib/languagetool-language-order.ts b/src/lib/languagetool-language-order.ts new file mode 100644 index 00000000..94691d20 --- /dev/null +++ b/src/lib/languagetool-language-order.ts @@ -0,0 +1,132 @@ +/** + * Build LanguageTool `language` codes with UI language first, then English, then German, then others. + * @see https://api.languagetool.org/v2/languages + */ +const LT_ALIASES: Record = { + en: 'en-US', + de: 'de-DE', + fr: 'fr-FR', + es: 'es', + it: 'it', + pt: 'pt-BR', + 'pt-BR': 'pt-BR', + 'pt-PT': 'pt-PT', + pl: 'pl-PL', + ru: 'ru-RU', + uk: 'uk-UA', + nl: 'nl', + sv: 'sv', + da: 'da-DK', + no: 'no', + nb: 'no', + nn: 'no', + fi: 'fi', + el: 'el-GR', + tr: 'tr', + ar: 'ar', + he: 'he', + hi: 'hi', + ja: 'ja-JP', + ko: 'ko', + zh: 'zh-CN', + fa: 'fa', + th: 'th-TH', + vi: 'vi-VN', + ro: 'ro-RO', + cs: 'cs-CZ', + sk: 'sk-SK', + hu: 'hu', + sl: 'sl-SI', + hr: 'hr-HR', + sr: 'sr', + bg: 'bg-BG', + lt: 'lt-LT', + lv: 'lv-LV', + et: 'et-EE', + ca: 'ca-ES', + gl: 'gl-ES', + tl: 'tl-PH', + id: 'id', + ms: 'ms-MY', + ta: 'ta-IN', + te: 'te-IN', + mr: 'mr-IN', + bn: 'bn-BD', + gu: 'gu-IN', + kn: 'kn-IN', + ml: 'ml-IN', + pa: 'pa-IN', + or: 'or-IN', + as: 'as-IN', + ne: 'ne-NP', + si: 'si-LK', + lo: 'lo-LA', + km: 'km-KH', + my: 'my-MM', + ka: 'ka-GE', + hy: 'hy-AM', + az: 'az', + kk: 'kk-KZ', + mn: 'mn-MN', + af: 'af', + sw: 'sw', + zu: 'zu', + xh: 'xh', + yo: 'yo', + ig: 'ig', + ha: 'ha', + so: 'so-SO', + am: 'am', + ti: 'ti', + om: 'om-ET', + sn: 'sn-ZW', + rw: 'rw', + mg: 'mg-MG', + ny: 'ny-MW', + eo: 'eo', + lb: 'lb', + br: 'br-FR', + cy: 'cy-GB', + ga: 'ga-IE', + gd: 'gd-GB', + mt: 'mt-MT', + is: 'is-IS', + fo: 'fo-FO', + tk: 'tk-TM', + uz: 'uz-UZ', + ky: 'ky-KG', + tg: 'tg-TJ', + ps: 'ps-AF', + sd: 'sd-IN', + ur: 'ur-PK', + ckb: 'ckb-IQ', + ku: 'kmr-Latn', + yi: 'yi-001', + jv: 'jv-Latn', + su: 'su-Latn' +} + +function mapI18nToLt(i18nLanguage: string): string { + const base = i18nLanguage.split(/[-_]/u)[0]?.toLowerCase() ?? 'en' + const full = i18nLanguage.replace('_', '-') + if (LT_ALIASES[full]) return LT_ALIASES[full]! + if (LT_ALIASES[base]) return LT_ALIASES[base]! + return 'en-US' +} + +export function buildLanguageToolPreferenceList(i18nLanguage: string | undefined): string[] { + const primary = mapI18nToLt((i18nLanguage ?? 'en').trim() || 'en') + const ordered: string[] = [] + const push = (c: string) => { + if (!ordered.includes(c)) ordered.push(c) + } + push(primary) + push('en-US') + push('de-DE') + const extras = Object.values(LT_ALIASES) + extras.sort() + for (const c of extras) { + push(c) + } + return ordered +} diff --git a/src/lib/live-activities.ts b/src/lib/live-activities.ts index 98b74e12..1cbd0abf 100644 --- a/src/lib/live-activities.ts +++ b/src/lib/live-activities.ts @@ -36,6 +36,15 @@ export type LiveActivitiesFetchEventsFn = ( /** NIP-53 live streaming (30311), meeting space (30312), meeting (30313). */ export const LIVE_ACTIVITY_KINDS = [30311, 30312, 30313] as const +/** True when the home kind picker (or “see all events”) allows NIP-53 live activity rows and inline embeds. */ +export function liveActivityKindsEnabledInPicker( + showKinds: readonly number[], + feedKindFilterBypass: boolean +): boolean { + if (feedKindFilterBypass) return true + return LIVE_ACTIVITY_KINDS.some((k) => showKinds.includes(k)) +} + /** * Stable NIP-33 address `kind:pubkey:d` for a live-activity replaceable event (carousel dedupe / user hide list). * Must match {@link parseLiveActivityEvent} `address` and {@link dedupeLatestForLiveTicker} keys exactly diff --git a/src/lib/read-aloud-translation-override.ts b/src/lib/read-aloud-translation-override.ts new file mode 100644 index 00000000..e371dcfa --- /dev/null +++ b/src/lib/read-aloud-translation-override.ts @@ -0,0 +1,18 @@ +/** + * Optional plain text used for the next read-aloud instead of deriving text from the event + * (e.g. after translating in the advanced lab). One-shot per event id. + */ +const overrides = new Map() + +export function setReadAloudTranslationForEvent(eventId: string, plainText: string): void { + overrides.set(eventId, plainText) +} + +export function takeReadAloudTranslationForEvent(eventId: string): string | undefined { + const v = overrides.get(eventId) + if (v !== undefined) { + overrides.delete(eventId) + return v + } + return undefined +} diff --git a/src/lib/read-aloud.ts b/src/lib/read-aloud.ts index c705a67f..b834472d 100644 --- a/src/lib/read-aloud.ts +++ b/src/lib/read-aloud.ts @@ -1,4 +1,5 @@ import { ExtendedKind, READ_ALOUD_TTS_URL } from '@/constants' +import { takeReadAloudTranslationForEvent } from '@/lib/read-aloud-translation-override' import { buildPiperTtsCacheKey, getPiperTtsCacheBudget, @@ -671,7 +672,10 @@ export async function speakNoteReadAloud(event: Event): Promise return 'unsupported' } - const text = buildReadAloudPlainText(event) + const translationOverride = takeReadAloudTranslationForEvent(event.id) + const text = translationOverride + ? stripMarkupForReadAloud(translationOverride) + : buildReadAloudPlainText(event) if (!text) { return 'empty' } diff --git a/src/lib/tiptap-plaintext.test.ts b/src/lib/tiptap-plaintext.test.ts new file mode 100644 index 00000000..0d6dfb67 --- /dev/null +++ b/src/lib/tiptap-plaintext.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { parseEditorJsonToText, plainTextToTipTapDoc } from '@/lib/tiptap' + +describe('plainTextToTipTapDoc', () => { + it('round-trips simple lines', () => { + const plain = 'hello\nworld' + const doc = plainTextToTipTapDoc(plain) + expect(parseEditorJsonToText(doc).trim()).toBe(plain) + }) + + it('handles empty string', () => { + const doc = plainTextToTipTapDoc('') + expect(parseEditorJsonToText(doc).trim()).toBe('') + }) + + it('handles blank line between paragraphs', () => { + const plain = 'a\n\nb' + const doc = plainTextToTipTapDoc(plain) + expect(parseEditorJsonToText(doc).trim()).toBe(plain) + }) + + it('normalizes CRLF', () => { + const doc = plainTextToTipTapDoc('x\r\ny') + expect(parseEditorJsonToText(doc).trim()).toBe('x\ny') + }) +}) diff --git a/src/lib/tiptap.ts b/src/lib/tiptap.ts index 3066cb90..00a5e506 100644 --- a/src/lib/tiptap.ts +++ b/src/lib/tiptap.ts @@ -4,6 +4,20 @@ import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji' import { JSONContent } from '@tiptap/react' import { nip19 } from 'nostr-tools' +/** + * Build a minimal TipTap document from plain text (one paragraph per `\n`). + * Used when applying edits from the advanced CodeMirror lab so `parseEditorJsonToText` stays consistent. + */ +export function plainTextToTipTapDoc(plain: string): JSONContent { + const normalized = plain.replace(/\r\n/g, '\n') + const lines = normalized.length === 0 ? [''] : normalized.split('\n') + const paragraphs: JSONContent[] = lines.map((line) => ({ + type: 'paragraph', + content: line ? [{ type: 'text', text: line }] : [] + })) + return { type: 'doc', content: paragraphs } +} + export function parseEditorJsonToText(node?: JSONContent) { const rawJoined = _parseEditorJsonToText(node) let text = rawJoined.trim() diff --git a/src/lib/translate-client.ts b/src/lib/translate-client.ts new file mode 100644 index 00000000..fb55f53e --- /dev/null +++ b/src/lib/translate-client.ts @@ -0,0 +1,67 @@ +import { TRANSLATE_URL } from '@/constants' +import logger from '@/lib/logger' +import { sha256 } from '@noble/hashes/sha256' +import { bytesToHex } from '@noble/hashes/utils' + +const memoryCache = new Map() +const MAX_MEMORY = 80 +const CACHE_TTL_MS = 1000 * 60 * 60 * 24 + +function cacheKey(source: string, sourceLang: string, targetLang: string): string { + const h = bytesToHex(sha256(new TextEncoder().encode(`${sourceLang}|${targetLang}|${source}`))) + return h +} + +function pruneMemory(): void { + const now = Date.now() + for (const [k, v] of memoryCache) { + if (now - v.at > CACHE_TTL_MS) memoryCache.delete(k) + } + while (memoryCache.size > MAX_MEMORY) { + const first = memoryCache.keys().next().value + if (first) memoryCache.delete(first) + else break + } +} + +export function isTranslateConfigured(): boolean { + return Boolean(TRANSLATE_URL.trim()) +} + +export async function translatePlainText( + text: string, + targetLang: string, + sourceLang: string = 'auto' +): Promise { + const base = TRANSLATE_URL.trim().replace(/\/$/u, '') + if (!base) { + throw new Error('Translation URL not configured') + } + const key = cacheKey(text, sourceLang, targetLang) + const hit = memoryCache.get(key) + if (hit && Date.now() - hit.at < CACHE_TTL_MS) { + return hit.text + } + + const url = `${base}/translate` + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + q: text, + source: sourceLang, + target: targetLang, + format: 'text' + }) + }) + if (!res.ok) { + const err = await res.text().catch(() => '') + logger.warn('[Translate] HTTP error', { status: res.status, err: err.slice(0, 200) }) + throw new Error(`Translate: ${res.status}`) + } + const data = (await res.json()) as { translatedText?: string } + const out = data.translatedText ?? '' + pruneMemory() + memoryCache.set(key, { text: out, at: Date.now() }) + return out +} diff --git a/vite.config.ts b/vite.config.ts index 0ce34146..d7a74ab6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -108,6 +108,16 @@ export default defineConfig(({ mode }) => { target: 'http://127.0.0.1:9876', changeOrigin: true }, + '/api/languagetool': { + target: 'http://127.0.0.1:8010', + changeOrigin: true, + rewrite: (p) => p.replace(/^\/api\/languagetool/u, '') || '/' + }, + '/api/translate': { + target: 'http://127.0.0.1:5000', + changeOrigin: true, + rewrite: (p) => p.replace(/^\/api\/translate/u, '') || '/' + }, '/sites': { target: 'http://127.0.0.1:8090', changeOrigin: true