diff --git a/.agents/skills/update-hebrew-transliteration.md b/.agents/skills/update-hebrew-transliteration.md new file mode 100644 index 0000000..2e672b7 --- /dev/null +++ b/.agents/skills/update-hebrew-transliteration.md @@ -0,0 +1,124 @@ +# Update `hebrew-transliteration` Package + +Steps to update the `hebrew-transliteration` npm package and sync the application's UI with any new/changed schema options. + +## 1. Update the package version + +```bash +npx npm-check-updates hebrew-transliteration -u +npm install +``` + +This updates `package.json` and installs the new version. + +## 2. Extract updated preset schemas + +```bash +node scripts/extract-schemas.ts +``` + +This re-serializes all 9 preset schemas from the package into `public/schemas/` and updates `public/schemas/manifest.json` with the new version number. + +## 3. Identify changes between versions + +### 3a. Compare type definitions + +```bash +# Download old version for comparison +mkdir -p /tmp/ht-old +npm pack hebrew-transliteration@ --pack-destination /tmp/ht-old +mkdir -p /tmp/ht-old/pkg && tar -xzf /tmp/ht-old/hebrew-transliteration-.tgz -C /tmp/ht-old/pkg + +# Compare Schema types +diff /tmp/ht-old/pkg/package/dist/types/schema.d.ts node_modules/hebrew-transliteration/dist/schema.d.ts + +# Compare Remove types +diff /tmp/ht-old/pkg/package/dist/types/remove.d.ts node_modules/hebrew-transliteration/dist/remove.d.ts + +# Clean up +rm -rf /tmp/ht-old +``` + +### 3b. Check for new/removed preset schemas + +Compare `node_modules/hebrew-transliteration/dist/schemas/` against `public/schemas/manifest.json`: + +```bash +ls node_modules/hebrew-transliteration/dist/schemas/*.d.ts +``` + +### 3c. Check havarotjs changes (transitive dependency) + +The `SylOpts` interface in `node_modules/havarotjs/dist/types/text.d.ts` may have new properties that flow into the Schema type. + +```bash +# Download old havarotjs +npm pack havarotjs@ --pack-destination /tmp/ht-old +# Extract and compare text.d.ts +``` + +## 4. Map new/changed Schema properties to UI + +Check the `SettingsDialog.svelte` component for what's already present: + +**Consonants tab** (`src/components/transliterate/SettingsDialog.svelte`): + +- Array `consonants` (line ~125) — list of all consonant keys +- Compare against Schema type's consonant properties (ALEF through TAV_DAGESH) + +**Vowels tab** (same file): + +- Array `vowels` (line ~162) — list of all vowel keys +- Compare against Schema type's vowel properties (VOCAL_SHEVA through SHUREQ, plus FURTIVE_PATAH, QAMATS_QATAN, and \*HE ligatures) + +**Others tab** (same file): + +- Array `orthographies` (line ~188) — MS_SUFX, DIVINE_NAME, DIVINE_NAME_ELOHIM, SYLLABLE_SEPARATOR, DAGESH, MAQAF, PASEQ, SOF_PASUQ +- `DAGESH_CHAZAQ` — radio group (none/double/custom) +- `STRESS_MARKER` — switch + location/mark/exclude selects +- `ketivQeres` — add/remove pair list +- `ON_COMPLETE` — toggle + textarea + +**Syllabification tab** (same file): + +- Array `syllabification` (line ~199) — boolean/string dropdowns +- Compare against Schema's SylOpts properties + +### Remove Settings (`src/components/remove/Settings.svelte`): + +- Tabs: Punctuation, Taamim, Vowels — each with checkboxes +- `ON_COMPLETE` — toggle + textarea at bottom of dialog + +## 5. Serialization support + +When adding properties that involve functions or RegExps: + +- **`src/lib/schemaSerialization.ts`** — `serialize_schema` and `deserialize_schema` pass the full schema through `serialize_object`/`deserialize_object`, which handles: + - Functions → `{ __function: "..." }` + - RegExps → `{ __regexp: "...", flags: "..." }` +- If adding a new property type that needs special serialization, update `serialize_value`/`deserialize_value` +- For the **remove page**: `src/components/remove/index.svelte` wraps the options with `serialize_object`/`deserialize_object` for localStorage + +## 6. Verify + +```bash +npm run lint +npm run build +npm run test +``` + +## Property categories reference + +| Category | Where it lives | Example properties | +| --------------------- | --------------------------------- | --------------------------------------------------------------------------- | +| Consonants | Schema (required) | ALEF, BET, BET_DAGESH, ..., TAV_DAGESH | +| Vowels | Schema (required) | VOCAL_SHEVA, PATAH, QAMATS, HIRIQ_YOD, PATAH_HE, ... | +| Orthographic features | Schema (optional) | MS_SUFX, DIVINE_NAME, DIVINE_NAME_ELOHIM, SYLLABLE_SEPARATOR | +| Marks | Schema (optional) | DAGESH, MAQAF, PASEQ, SOF_PASUQ | +| Dagesh Chazaq | Schema (optional) | DAGESH_CHAZAQ: boolean \| string | +| Stress Marker | Schema (optional) | STRESS_MARKER: `{ location, mark, exclude? }` | +| Additional Features | Schema (optional) | ADDITIONAL_FEATURES: WordFeature[] \| SyllableFeature[] \| ClusterFeature[] | +| Ketiv/Qere | Schema (optional, SylOpts) | ketivQeres: KetivQere[] | +| ON_COMPLETE | Schema + RemoveOptions (optional) | ON_COMPLETE: `(result, context) => string` | +| Syllabification | Schema (optional, SylOpts) | allowNoNiqqud, article, holemHaser, ketivQeres, longVowels, ... | +| Remove options | RemoveOptions (optional) | ETNAHTA, SHEVA, MAQAF, PUNC_GERESH, ... | diff --git a/package-lock.json b/package-lock.json index b3af3fd..fec3ac4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "astro": "^6.0.4", "google-auth-library": "^10.6.2", "google-spreadsheet": "^5.2.0", - "hebrew-transliteration": "^2.9.1", + "hebrew-transliteration": "^2.10.0", "svelte-codemirror-editor": "^2.1.0", "tailwindcss": "^4.2.1", "typescript": "^5.9.3" @@ -27,7 +27,7 @@ "@internationalized/date": "^3.12.0", "@lucide/svelte": "^0.577.0", "@netlify/functions": "^5.1.5", - "@playwright/test": "^1.58.2", + "@playwright/test": "^1.61.0", "@types/node": "^25.5.0", "bits-ui": "^2.16.3", "clsx": "^2.1.1", @@ -35,6 +35,7 @@ "eslint-plugin-svelte": "^3.15.2", "globals": "^17.4.0", "mode-watcher": "^1.1.0", + "npm-check-updates": "^22.2.7", "prettier": "^3.8.1", "prettier-plugin-astro": "^0.14.1", "prettier-plugin-svelte": "^3.5.1", @@ -3964,13 +3965,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", - "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", + "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.58.2" + "playwright": "1.61.0" }, "bin": { "playwright": "cli.js" @@ -9087,21 +9088,21 @@ } }, "node_modules/havarotjs": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/havarotjs/-/havarotjs-0.25.2.tgz", - "integrity": "sha512-eh0fZVxJ03Gbropua/v0mqNxHtudU/IKgxlbT71Cfq96FMhLP4UaIRchrVlkfR4e6TuStUPnK0jkbgYB2psakQ==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/havarotjs/-/havarotjs-0.25.4.tgz", + "integrity": "sha512-DKXQ3GGfNVQxkW2ZY8bYDbTh0GuOX3/hotRiVQCDTksY1k/M283oQjfgBgXg0vO4HvCLl2WDW7EfLWaKUH7S4Q==", "license": "MIT", "engines": { "node": ">=15.0.0" } }, "node_modules/hebrew-transliteration": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/hebrew-transliteration/-/hebrew-transliteration-2.9.1.tgz", - "integrity": "sha512-jb6QlkUoBrTDR9K8gpWmJGfFoqGmi2MMARI+JqnTHEwGBYJBMQ2779yi5NQF+mhD+1Lm4Wabc198c+t5zqNwNA==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/hebrew-transliteration/-/hebrew-transliteration-2.10.0.tgz", + "integrity": "sha512-lNdGt1Y9JyulLZCXGC7xEO9vit6XGmkA78UHxI5yIeq0IOPqxH9zYiZM+VkMZ7Ikez5rySGW1CfIb5ZBx3HO0A==", "license": "MIT", "dependencies": { - "havarotjs": "0.25.2" + "havarotjs": "0.25.4" } }, "node_modules/hosted-git-info": { @@ -11925,6 +11926,21 @@ "node": ">=0.10.0" } }, + "node_modules/npm-check-updates": { + "version": "22.2.7", + "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-22.2.7.tgz", + "integrity": "sha512-Pzs/lTswEe23hT8JUXABl4vgrr0WRKOhw55wAldwngUflk8CwP+grXJt00yGjYiZf2wD1HIfdPa3xaOi1RCyZQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "ncu": "build/cli.js", + "npm-check-updates": "build/cli.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10.0.0" + } + }, "node_modules/npm-run-path": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", @@ -12490,13 +12506,13 @@ } }, "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", + "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.2" + "playwright-core": "1.61.0" }, "bin": { "playwright": "cli.js" @@ -12509,9 +12525,9 @@ } }, "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index 5cd8b3f..b571e01 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "astro": "^6.0.4", "google-auth-library": "^10.6.2", "google-spreadsheet": "^5.2.0", - "hebrew-transliteration": "^2.9.1", + "hebrew-transliteration": "^2.10.0", "svelte-codemirror-editor": "^2.1.0", "tailwindcss": "^4.2.1", "typescript": "^5.9.3" @@ -43,7 +43,7 @@ "@internationalized/date": "^3.12.0", "@lucide/svelte": "^0.577.0", "@netlify/functions": "^5.1.5", - "@playwright/test": "^1.58.2", + "@playwright/test": "^1.61.0", "@types/node": "^25.5.0", "bits-ui": "^2.16.3", "clsx": "^2.1.1", @@ -51,6 +51,7 @@ "eslint-plugin-svelte": "^3.15.2", "globals": "^17.4.0", "mode-watcher": "^1.1.0", + "npm-check-updates": "^22.2.7", "prettier": "^3.8.1", "prettier-plugin-astro": "^0.14.1", "prettier-plugin-svelte": "^3.5.1", diff --git a/src/components/remove/Settings.svelte b/src/components/remove/Settings.svelte index c9f23b7..0ca134e 100644 --- a/src/components/remove/Settings.svelte +++ b/src/components/remove/Settings.svelte @@ -4,10 +4,14 @@ import { DialogDescription, DialogHeader, DialogTitle } from "$lib/components/ui/dialog/index.js"; import { Label } from "$lib/components/ui/label/index.js"; import { ScrollArea } from "$lib/components/ui/scroll-area/index.js"; + import { Switch } from "$lib/components/ui/switch/index.js"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "$lib/components/ui/tabs/index.js"; + import { javascript } from "@codemirror/lang-javascript"; + import { EditorView } from "@codemirror/view"; import IconInfoCircle from "@tabler/icons-svelte/icons/info-circle"; import type { RemoveOptions } from "hebrew-transliteration"; import { getContext } from "svelte"; + import CodeMirror from "svelte-codemirror-editor"; import type { Context, RemoveState } from "../../types/index"; import { format_doc_url } from "../../utils/documentation"; import Dialog from "../shared/Dialog.svelte"; @@ -81,15 +85,15 @@ let active_tab = $state("punctuation"); - function handle_option_change(key: keyof RemoveOptions, checked: boolean | "indeterminate") { + function handle_option_change(key: string, checked: boolean | "indeterminate") { remove_state.value.options = { ...remove_state.value.options, [key]: checked === true, }; } - function is_option_checked(key: keyof RemoveOptions): boolean { - return remove_state.value.options[key as keyof RemoveOptions] === true; + function is_option_checked(key: string): boolean { + return (remove_state.value.options as Record)[key] === true; } function handle_dialog_change(open: boolean) { @@ -100,6 +104,65 @@ return format_doc_url("/api/interfaces/removeoptions", key); } + let on_complete_enabled = $state(false); + let on_complete_code = $state(""); + + function load_on_complete() { + const oc = remove_state.value.options.ON_COMPLETE; + if (typeof oc === "function") { + on_complete_enabled = true; + on_complete_code = oc.toString(); + } else { + on_complete_enabled = false; + on_complete_code = ""; + } + } + + function handle_on_complete_toggle(enabled: boolean) { + on_complete_enabled = enabled; + if (!enabled) { + remove_state.value.options = { ...remove_state.value.options, ON_COMPLETE: undefined }; + } else { + const defaultFn = `(result, context) => {\n return result;\n}`; + on_complete_code = defaultFn; + try { + const fn = eval(defaultFn); + remove_state.value.options = { ...remove_state.value.options, ON_COMPLETE: fn }; + } catch { + // ignore parse errors while editing + } + } + } + + $effect(() => { + load_on_complete(); + }); + + $effect(() => { + const code = on_complete_code; + if (on_complete_enabled && code) { + const opt_fn = remove_state.value.options.ON_COMPLETE; + if (typeof opt_fn === "function" && opt_fn.toString() === code) return; + try { + remove_state.value.options = { ...remove_state.value.options, ON_COMPLETE: eval(code) }; + } catch { + // ignore parse errors while editing + } + } + }); + + const on_complete_extensions = [ + javascript(), + EditorView.theme({ + ".cm-gutters": { + backgroundColor: "var(--muted)", + color: "var(--muted-foreground)", + border: "none", + }, + ".cm-gutterElement": { backgroundColor: "var(--muted)", color: "var(--muted-foreground)" }, + }), + ]; + function toggle_all(category: "punctuation" | "taamim" | "vowels") { const items = category === "punctuation" ? punctuation : category === "taamim" ? taamim : vowels; @@ -108,7 +171,7 @@ const new_options = { ...remove_state.value.options }; for (const item of items) { - new_options[item.key as keyof RemoveOptions] = !allChecked; + (new_options as Record)[item.key] = !allChecked; } remove_state.value.options = new_options; } @@ -127,6 +190,7 @@ Punctuation Taamim Vowels + On Complete @@ -205,5 +269,41 @@ + + +
+ +

+ A callback invoked when removal is complete. Receives + (result, context) where context contains original and + options. +

+
+ + +
+ {#if on_complete_enabled} +
+ +
+ {/if} +
+
diff --git a/src/components/remove/index.svelte b/src/components/remove/index.svelte index 72f7edd..f507bee 100644 --- a/src/components/remove/index.svelte +++ b/src/components/remove/index.svelte @@ -1,8 +1,10 @@ + + { + if (!open) on_cancel(); + }} +> + + + Edit Ketiv/Qere Output Callback + + Edit the callback function for this ketiv/qere pair. Receives (text, input). + + + +
+
+ +
+ +
+

+ Use schema["KEY"] to access schema transliteration values. +

+
+
+ + + + + +
+
diff --git a/src/components/transliterate/SettingsDialog.svelte b/src/components/transliterate/SettingsDialog.svelte index 69c42ba..5e98799 100644 --- a/src/components/transliterate/SettingsDialog.svelte +++ b/src/components/transliterate/SettingsDialog.svelte @@ -18,9 +18,12 @@ import { ScrollArea } from "$lib/components/ui/scroll-area/index.js"; import { Switch } from "$lib/components/ui/switch/index.js"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "$lib/components/ui/tabs/index.js"; + import { javascript } from "@codemirror/lang-javascript"; + import { EditorView } from "@codemirror/view"; import IconInfoCircle from "@tabler/icons-svelte/icons/info-circle"; import type { SBL } from "hebrew-transliteration"; import { getContext } from "svelte"; + import CodeMirror from "svelte-codemirror-editor"; import { toast } from "svelte-sonner"; import { track_schema_change } from "../../lib/analytics"; import { @@ -34,6 +37,7 @@ import { get_default_SBL_schema } from "../../utils/schemaDefaults"; import Dialog from "../shared/Dialog.svelte"; import FeatureEditor from "./FeatureEditor.svelte"; + import KetivQereEditor from "./KetivQereEditor.svelte"; const transliteration_state = getContext>("transliteration_state"); @@ -82,6 +86,163 @@ let editing_index = $state(null); let feature_editor_visibility: VisibilityState = $state("hidden"); + // Ketiv/Qere state + interface KetivQerePair { + input: string; + output: string; + is_callback: boolean; + ignoreTaamim: boolean; + captureTaamim: boolean; + } + + let ketiv_qere_pairs = $state([]); + let kq_editor_index = $state(null); + let kq_editor_code = $state(""); + + function load_ketiv_qere_pairs() { + const kq = transliteration_state.value.schema.ketivQeres; + if (kq && Array.isArray(kq)) { + ketiv_qere_pairs = kq.map((item) => ({ + input: + typeof item.input === "string" + ? item.input + : item.input instanceof RegExp + ? `/${item.input.source}/${item.input.flags}` + : "", + output: + typeof item.output === "string" + ? item.output + : typeof item.output === "function" + ? item.output.toString() + : "", + is_callback: typeof item.output === "function", + ignoreTaamim: item.ignoreTaamim ?? true, + captureTaamim: item.captureTaamim ?? false, + })); + } else { + ketiv_qere_pairs = []; + } + } + + function add_ketiv_qere_pair() { + mark_as_custom_if_needed(); + ketiv_qere_pairs = [ + ...ketiv_qere_pairs, + { input: "", output: "", is_callback: false, ignoreTaamim: true, captureTaamim: false }, + ]; + } + + function update_ketiv_qere_pair(index: number, field: string, value: string | boolean) { + mark_as_custom_if_needed(); + const updated = ketiv_qere_pairs.map((pair, i) => + i === index ? { ...pair, [field]: value } : pair, + ); + ketiv_qere_pairs = updated; + commit_ketiv_qere_pairs(updated); + } + + function remove_ketiv_qere_pair(index: number) { + mark_as_custom_if_needed(); + const updated = ketiv_qere_pairs.filter((_, i) => i !== index); + ketiv_qere_pairs = updated; + commit_ketiv_qere_pairs(updated); + } + + function open_kq_callback_editor(index: number) { + kq_editor_index = index; + kq_editor_code = ketiv_qere_pairs[index].output; + } + + function save_kq_callback_editor(code: string) { + const index = kq_editor_index; + if (index === null) return; + update_ketiv_qere_pair(index, "output", code); + kq_editor_index = null; + kq_editor_code = ""; + } + + function cancel_kq_callback_editor() { + kq_editor_index = null; + kq_editor_code = ""; + } + + function toggle_kq_output_type(index: number, is_callback: boolean) { + mark_as_custom_if_needed(); + if (is_callback) { + const defaultFn = `(text, input) => {\n return text.replace(input, "");\n}`; + const updated = ketiv_qere_pairs.map((p, i) => + i === index ? { ...p, is_callback: true, output: defaultFn } : p, + ); + ketiv_qere_pairs = updated; + commit_ketiv_qere_pairs(updated); + } else { + const updated = ketiv_qere_pairs.map((p, i) => + i === index ? { ...p, is_callback: false, output: "" } : p, + ); + ketiv_qere_pairs = updated; + commit_ketiv_qere_pairs(updated); + } + } + + function commit_ketiv_qere_pairs(pairs: KetivQerePair[]) { + const ketivQeres = pairs.map((p) => { + const input: string | RegExp = + p.input.startsWith("/") && p.input.lastIndexOf("/") > 0 + ? (() => { + try { + const m = p.input.match(/^\/(.*)\/([gimsu]*)$/); + return m ? new RegExp(m[1], m[2]) : p.input; + } catch { + return p.input; + } + })() + : p.input; + const output = p.is_callback + ? (() => { + try { + return eval(p.output); + } catch { + return p.output; + } + })() + : p.output; + return { input, output, ignoreTaamim: p.ignoreTaamim, captureTaamim: p.captureTaamim }; + }); + handle_update_schema("ketivQeres", ketivQeres); + } + + // ON_COMPLETE state + let on_complete_enabled = $state(false); + let on_complete_code = $state(""); + + function load_on_complete() { + const oc = transliteration_state.value.schema.ON_COMPLETE; + if (typeof oc === "function") { + on_complete_enabled = true; + on_complete_code = oc.toString(); + } else { + on_complete_enabled = false; + on_complete_code = ""; + } + } + + function handle_on_complete_toggle(enabled: boolean) { + mark_as_custom_if_needed(); + on_complete_enabled = enabled; + if (!enabled) { + handle_update_schema("ON_COMPLETE", undefined); + } else { + const defaultFn = `(result, context) => {\n return result;\n}`; + on_complete_code = defaultFn; + try { + const fn = eval(defaultFn); + handle_update_schema("ON_COMPLETE", fn); + } catch { + // ignore parse errors while editing + } + } + } + function load_additional_features() { additional_features = transliteration_state.value.schema.ADDITIONAL_FEATURES || []; } @@ -662,7 +823,35 @@ $effect(() => { load_additional_features(); + load_ketiv_qere_pairs(); + load_on_complete(); }); + + $effect(() => { + const code = on_complete_code; + if (on_complete_enabled && code) { + const schema_fn = transliteration_state.value.schema.ON_COMPLETE; + if (typeof schema_fn === "function" && schema_fn.toString() === code) return; + mark_as_custom_if_needed(); + try { + handle_update_schema("ON_COMPLETE", eval(code)); + } catch { + // ignore parse errors while editing + } + } + }); + + const on_complete_extensions = [ + javascript(), + EditorView.theme({ + ".cm-gutters": { + backgroundColor: "var(--muted)", + color: "var(--muted-foreground)", + border: "none", + }, + ".cm-gutterElement": { backgroundColor: "var(--muted)", color: "var(--muted-foreground)" }, + }), + ]; @@ -790,6 +979,128 @@ {/each} + +
+ +

+ Define pairs to replace specific Hebrew text patterns (e.g. ketiv/qere, irregular + spellings). Each pair replaces the input text with the output text prior to + syllabification. +

+ {#each ketiv_qere_pairs as pair, index (index)} +
+
+ + + update_ketiv_qere_pair( + index, + "input", + (e.currentTarget as HTMLInputElement).value, + )} + /> +
+
+ + + toggle_kq_output_type(index, value === "callback")} + > +
+
+ + +
+
+ + +
+
+
+ {#if pair.is_callback} +
+ (callback) + +
+ {:else} + + update_ketiv_qere_pair( + index, + "output", + (e.currentTarget as HTMLInputElement).value, + )} + /> + {/if} +
+
+
+ + update_ketiv_qere_pair( + index, + "ignoreTaamim", + (e.currentTarget as HTMLInputElement).checked, + )} + class="w-3 h-3" + /> + +
+
+ + update_ketiv_qere_pair( + index, + "captureTaamim", + (e.currentTarget as HTMLInputElement).checked, + )} + class="w-3 h-3" + /> + +
+
+ +
+ {/each} + +
+ + {#if kq_editor_index !== null} + + {/if} @@ -989,6 +1300,44 @@ + +
+ +

+ A callback invoked when transliteration is complete. Receives + (result, context) where context contains original, + schema, and text. +

+
+ + +
+ {#if on_complete_enabled} +
+ +
+ {/if} +
+ diff --git a/src/lib/schemaSerialization.ts b/src/lib/schemaSerialization.ts index 9b06ac4..129c39c 100644 --- a/src/lib/schemaSerialization.ts +++ b/src/lib/schemaSerialization.ts @@ -63,17 +63,9 @@ export function serialize_object(obj: unknown): unknown { } export function serialize_schema(schema: Partial): Partial { - if (!schema.ADDITIONAL_FEATURES) return schema; - return { - ...schema, - ADDITIONAL_FEATURES: serialize_object(schema.ADDITIONAL_FEATURES) as SBL["ADDITIONAL_FEATURES"], - }; + return serialize_object(schema) as Partial; } export function deserialize_schema(data: Partial): Partial { - if (!data.ADDITIONAL_FEATURES) return data; - return { - ...data, - ADDITIONAL_FEATURES: deserialize_object(data.ADDITIONAL_FEATURES) as SBL["ADDITIONAL_FEATURES"], - }; + return deserialize_object(data) as Partial; } diff --git a/tests/remove.spec.ts b/tests/remove.spec.ts new file mode 100644 index 0000000..ed8a088 --- /dev/null +++ b/tests/remove.spec.ts @@ -0,0 +1,49 @@ +import { expect, test } from "@playwright/test"; + +test.describe("/remove", () => { + test("on_compolete: toggle enables editor, persists after reload, can be disabled", async ({ + page, + }) => { + await page.goto("/remove", { waitUntil: "networkidle" }); + + const settingsBtn = page.getByTestId("settings-button"); + await settingsBtn.click(); + + await page.getByRole("tab", { name: "On Complete" }).click(); + + await page.getByTestId("enable-on-complete").click(); + + await expect(page.getByTestId("on-complete-editor")).toBeVisible(); + + const cm = page.getByTestId("on-complete-editor").locator(".cm-content"); + + await cm.click(); + + const cbText = "(result, context) => { return result.trim(); }"; + await page.keyboard.press("ControlOrMeta+A"); + await page.keyboard.type(cbText); + + // svelte-codemirror-editor debounces value updates by 300ms (handle_change). + // The debounce must fire and update Svelte state before save reads form.callback_code. + // This wait is necessary because programmatic CodeMirror edits bypass the normal + // input event flow that would otherwise trigger the binding synchronously. + // See: https://github.com/nicholasio/svelte-codemirror-editor + await page.waitForTimeout(500); + + await page.reload({ waitUntil: "networkidle" }); + + await page.getByTestId("settings-button").click(); + + await page.getByRole("tab", { name: "On Complete" }).click(); + + await expect(page.getByTestId("on-complete-editor")).toBeVisible({ timeout: 5000 }); + + const cm2 = page.getByTestId("on-complete-editor").locator(".cm-content"); + await expect(cm2).toContainText("result.trim()"); + + await page.getByTestId("enable-on-complete").click(); + await page.waitForTimeout(500); + + await expect(page.getByTestId("on-complete-editor")).not.toBeVisible(); + }); +}); diff --git a/tests/settings-dialog.spec.ts b/tests/settings-dialog.spec.ts deleted file mode 100644 index 3b48e9f..0000000 --- a/tests/settings-dialog.spec.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test.describe("SettingsDialog - Schema Modification", () => { - test.beforeEach(async ({ page }) => { - await page.goto("/"); - await page.waitForLoadState("networkidle"); - await page.evaluate(() => localStorage.clear()); - }); - - test.afterEach(async ({ page }) => { - await page.evaluate(() => localStorage.clear()); - }); - - test("select shows (user modified) after customizing a value", async ({ page }) => { - const settingsBtn = page.getByTestId("settings-button"); - await settingsBtn.click(); - - const schemaSelect = page.getByTestId("schema-select"); - await expect(schemaSelect).toBeVisible({ timeout: 15000 }); - - await page.getByRole("tab", { name: "Consonants" }).click(); - - const alefInput = page.locator('input[aria-label="ALEF"]'); - await expect(alefInput).toBeVisible({ timeout: 10000 }); - await alefInput.fill("custom-alef"); - await alefInput.blur(); - - await page.getByRole("tab", { name: "Schemas" }).click(); - - await expect(schemaSelect).toContainText("(user modified)"); - }); - - test("reset button is enabled after modification", async ({ page }) => { - const settingsBtn = page.getByTestId("settings-button"); - await settingsBtn.click(); - - const schemaSelect = page.getByTestId("schema-select"); - await expect(schemaSelect).toBeVisible({ timeout: 15000 }); - - await page.getByRole("tab", { name: "Consonants" }).click(); - - const alefInput = page.locator('input[aria-label="ALEF"]'); - await expect(alefInput).toBeVisible({ timeout: 10000 }); - await alefInput.fill("modified"); - await alefInput.blur(); - - await page.getByRole("tab", { name: "Schemas" }).click(); - - const resetButton = page.getByTestId("reset-button"); - await expect(resetButton).toBeEnabled(); - }); - - test("clicking reset reverts changes and disables button", async ({ page }) => { - const settingsBtn = page.getByTestId("settings-button"); - await settingsBtn.click(); - - const schemaSelect = page.getByTestId("schema-select"); - await expect(schemaSelect).toBeVisible({ timeout: 15000 }); - - await page.getByRole("tab", { name: "Consonants" }).click(); - - const alefInput = page.locator('input[aria-label="ALEF"]'); - await expect(alefInput).toBeVisible({ timeout: 10000 }); - await alefInput.fill("modified"); - await alefInput.blur(); - - await page.getByRole("tab", { name: "Schemas" }).click(); - - const resetButton = page.getByTestId("reset-button"); - await expect(resetButton).toBeEnabled(); - await resetButton.click(); - - await page.getByRole("tab", { name: "Consonants" }).click(); - - await expect(alefInput).toHaveValue("ʾ"); - - await page.getByRole("tab", { name: "Schemas" }).click(); - - const resetButtonAfter = page.getByTestId("reset-button"); - await expect(resetButtonAfter).toBeDisabled(); - }); - - test("SBL Academic resets to default schema when selected", async ({ page }) => { - const settingsBtn = page.getByTestId("settings-button"); - await settingsBtn.click(); - - const schemaSelect = page.getByTestId("schema-select"); - await expect(schemaSelect).toBeVisible({ timeout: 15000 }); - - await page.getByRole("tab", { name: "Consonants" }).click(); - - const alefInput = page.locator('input[aria-label="ALEF"]'); - await expect(alefInput).toBeVisible({ timeout: 10000 }); - await alefInput.fill("modified"); - await alefInput.blur(); - - await page.getByRole("tab", { name: "Schemas" }).click(); - - await expect(schemaSelect).toBeVisible({ timeout: 10000 }); - await schemaSelect.selectOption("SBL Academic"); - - await page.getByRole("tab", { name: "Consonants" }).click(); - - await expect(alefInput).toHaveValue("ʾ"); - }); - - test("after page reload, selected schema name persists", async ({ page }) => { - async function validateSchemaValue(stepName: string, value: string = "SBL Academic") { - await test.step(`Verification: ${stepName}`, async () => { - await expect(schemaSelect).toHaveValue(value); - }); - } - - const settingsBtn = page.getByTestId("settings-button"); - await settingsBtn.click(); - - const schemaSelect = page.getByTestId("schema-select"); - - await validateSchemaValue("Initial Load"); - - schemaSelect.selectOption("Tiberian"); - - await validateSchemaValue("After select change", "Tiberian"); - - await page.reload(); - await page.waitForLoadState("networkidle"); - - await settingsBtn.click(); - await expect(schemaSelect).toBeVisible({ timeout: 15000 }); - - await validateSchemaValue("After Reload", "Tiberian"); - }); - - test("after page reload, (user modified) suffix persists", async ({ page }) => { - const settingsBtn = page.getByTestId("settings-button"); - await settingsBtn.click(); - - const schemaSelect = page.getByTestId("schema-select"); - await expect(schemaSelect).toBeVisible({ timeout: 15000 }); - - schemaSelect.selectOption("Tiberian"); - - await page.getByRole("tab", { name: "Consonants" }).click(); - - const alefInput = page.locator('input[aria-label="ALEF"]'); - await expect(alefInput).toBeVisible({ timeout: 10000 }); - await alefInput.fill("custom-alef"); - await alefInput.blur(); - - await page.getByRole("tab", { name: "Schemas" }).click(); - - await expect(schemaSelect).toContainText("(user modified)"); - - await page.reload(); - await page.waitForLoadState("networkidle"); - - await settingsBtn.click(); - await expect(schemaSelect).toBeVisible({ timeout: 15000 }); - - await expect(schemaSelect).toContainText("Tiberian"); - await expect(schemaSelect).toContainText("(user modified)"); - }); -}); - -test.describe("SettingsDialog - Additional Features", () => { - test.beforeEach(async ({ page }) => { - await page.goto("/"); - await page.waitForLoadState("networkidle"); - }); - - test.afterEach(async ({ page }) => { - await page.evaluate(() => localStorage.clear()); - }); - - test("edit button opens feature editor without crashing after localStorage round-trip", async ({ - page, - }) => { - const settingsBtn = page.getByTestId("settings-button"); - await settingsBtn.click(); - - await page.getByRole("tab", { name: "Additional Features" }).click(); - - const featureCards = page.locator('[data-testid="edit-feature-button"]'); - const count = await featureCards.count(); - expect(count).toBeGreaterThan(0); - - await featureCards.first().click(); - - await expect(page.getByTestId("feature-type-select")).toBeVisible({ timeout: 5000 }); - await expect(page.getByTestId("hebrew-match-input")).toBeVisible({ timeout: 5000 }); - }); - - test("callback arrow function syntax preserved after localStorage round-trip", async ({ - page, - }) => { - const settingsBtn = page.getByTestId("settings-button"); - await settingsBtn.click(); - - await page.getByRole("tab", { name: "Additional Features" }).click(); - - await page.locator('[data-testid="edit-feature-button"]').first().click(); - - await expect(page.getByTestId("callback-editor")).toBeVisible({ timeout: 5000 }); - - const callbackEditor = page.locator('[data-testid="callback-editor"]'); - const callbackCodeBefore = await callbackEditor.locator(".cm-content").textContent(); - - expect(callbackCodeBefore).toContain("=>"); - expect(callbackCodeBefore).toContain("syllable"); - - await page.keyboard.press("Escape"); - - await page.reload(); - await page.waitForLoadState("networkidle"); - - await settingsBtn.click(); - - await page.getByRole("tab", { name: "Additional Features" }).click(); - - await page.locator('[data-testid="edit-feature-button"]').first().click(); - - await expect(page.getByTestId("callback-editor")).toBeVisible({ timeout: 5000 }); - - const callbackCodeAfter = await callbackEditor.locator(".cm-content").textContent(); - - expect(callbackCodeAfter).toContain("=>"); - expect(callbackCodeAfter).toContain("syllable"); - expect(callbackCodeAfter).toBe(callbackCodeBefore); - }); - - test("modified callback executes correctly after localStorage round-trip", async ({ page }) => { - const errors: string[] = []; - page.on("console", (msg) => { - if (msg.type() === "error") { - errors.push(msg.text()); - } - }); - - const openAdditionalFeatures = async () => { - await page.getByTestId("settings-button").click(); - await page.getByRole("tab", { name: "Additional Features" }).click(); - }; - - const openFeatureEditor = async (state: "add" | "edit") => { - await openAdditionalFeatures(); - if (state === "add") { - await page.getByTestId("add-feature-button").click(); - } else { - // get the last editor, simulating the feature added before reload - await page - .getByTestId(/additional-feature-\d/) - .last() - .getByTestId("edit-feature-button") - .click(); - } - const editor = page.getByTestId("additional-feature-editor"); - await expect(editor).toBeVisible({ timeout: 1000 }); - await expect(editor).toHaveAttribute("data-formstate", state); - return editor; - }; - - // add a new feature to ensure test works regardless of schema used - const featureEditor = await openFeatureEditor("add"); - - await featureEditor.getByTestId("feature-type-select").selectOption("cluster"); - await featureEditor.getByTestId("hebrew-match-input").fill("א"); - - const callBackOption = featureEditor.locator(`button[data-value="callback"]`); - await callBackOption.click(); - await expect(featureEditor.getByTestId("callback-editor")).toBeVisible({ timeout: 5000 }); - - const callBackText = - "(cluster, hebrew) => { return cluster.text.length > 0 ? 'PROCESSED' : cluster.text; }"; - - // find the CodeMirror editor - const codeMirror = featureEditor.locator(".cm-content"); - - // make the editor active - await codeMirror.click(); - - // simulate a user entering a new function - await page.keyboard.press("ControlOrMeta+A"); - await page.keyboard.type(callBackText); - - // svelte-codemirror-editor debounces value updates by 300ms (handle_change). - // The debounce must fire and update Svelte state before save reads form.callback_code. - // This wait is necessary because programmatic CodeMirror edits bypass the normal - // input event flow that would otherwise trigger the binding synchronously. - // See: https://github.com/nicholasio/svelte-codemirror-editor - await page.waitForTimeout(500); - - await featureEditor.getByTestId("save-button").click(); - - await page.reload(); - await page.waitForLoadState("networkidle"); - - const secondFeatureEditor = await openFeatureEditor("edit"); - await expect(secondFeatureEditor.locator(".cm-content")).toHaveText(callBackText); - }); - - test("regex features display correctly after localStorage round-trip", async ({ page }) => { - async function verifyRegexText(stepName: string) { - await test.step(`Verification: ${stepName}`, async () => { - const settingsBtn = page.getByTestId("settings-button"); - await settingsBtn.click(); - await page.getByRole("tab", { name: "Additional Features" }).click(); - - const hebrew = page.getByTestId("additional-feature-hebrew-0"); - - expect(hebrew).not.toHaveText("[object Object]"); - expect(hebrew).not.toHaveText("object"); - - expect(hebrew).toHaveText("/[\\u{05B4}\\u{05BB}]/u"); - }); - } - - await verifyRegexText("Initial Load"); - - await page.reload(); - await page.waitForLoadState("networkidle"); - - await verifyRegexText("After Reload"); - - await page.reload(); - await page.waitForLoadState("networkidle"); - - // a bug was happening where an extra "\" was being added - await verifyRegexText("2nd Reload"); - }); -}); diff --git a/tests/transliterate.spec.ts b/tests/transliterate.spec.ts new file mode 100644 index 0000000..899036d --- /dev/null +++ b/tests/transliterate.spec.ts @@ -0,0 +1,554 @@ +import { expect, test } from "@playwright/test"; + +test.describe("/transliterate", () => { + test.describe("Schema Modification", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + await page.waitForLoadState("networkidle"); + await page.evaluate(() => localStorage.clear()); + }); + + test.afterEach(async ({ page }) => { + await page.evaluate(() => localStorage.clear()); + }); + + test("select shows (user modified) after customizing a value", async ({ page }) => { + const settingsBtn = page.getByTestId("settings-button"); + await settingsBtn.click(); + + const schemaSelect = page.getByTestId("schema-select"); + await expect(schemaSelect).toBeVisible({ timeout: 15000 }); + + await page.getByRole("tab", { name: "Consonants" }).click(); + + const alefInput = page.locator('input[aria-label="ALEF"]'); + await expect(alefInput).toBeVisible({ timeout: 10000 }); + await alefInput.fill("custom-alef"); + await alefInput.blur(); + + await page.getByRole("tab", { name: "Schemas" }).click(); + + await expect(schemaSelect).toContainText("(user modified)"); + }); + + test("reset button is enabled after modification", async ({ page }) => { + const settingsBtn = page.getByTestId("settings-button"); + await settingsBtn.click(); + + const schemaSelect = page.getByTestId("schema-select"); + await expect(schemaSelect).toBeVisible({ timeout: 15000 }); + + await page.getByRole("tab", { name: "Consonants" }).click(); + + const alefInput = page.locator('input[aria-label="ALEF"]'); + await expect(alefInput).toBeVisible({ timeout: 10000 }); + await alefInput.fill("modified"); + await alefInput.blur(); + + await page.getByRole("tab", { name: "Schemas" }).click(); + + const resetButton = page.getByTestId("reset-button"); + await expect(resetButton).toBeEnabled(); + }); + + test("clicking reset reverts changes and disables button", async ({ page }) => { + const settingsBtn = page.getByTestId("settings-button"); + await settingsBtn.click(); + + const schemaSelect = page.getByTestId("schema-select"); + await expect(schemaSelect).toBeVisible({ timeout: 15000 }); + + await page.getByRole("tab", { name: "Consonants" }).click(); + + const alefInput = page.locator('input[aria-label="ALEF"]'); + await expect(alefInput).toBeVisible({ timeout: 10000 }); + await alefInput.fill("modified"); + await alefInput.blur(); + + await page.getByRole("tab", { name: "Schemas" }).click(); + + const resetButton = page.getByTestId("reset-button"); + await expect(resetButton).toBeEnabled(); + await resetButton.click(); + + await page.getByRole("tab", { name: "Consonants" }).click(); + + await expect(alefInput).toHaveValue("ʾ"); + + await page.getByRole("tab", { name: "Schemas" }).click(); + + const resetButtonAfter = page.getByTestId("reset-button"); + await expect(resetButtonAfter).toBeDisabled(); + }); + + test("SBL Academic resets to default schema when selected", async ({ page }) => { + const settingsBtn = page.getByTestId("settings-button"); + await settingsBtn.click(); + + const schemaSelect = page.getByTestId("schema-select"); + await expect(schemaSelect).toBeVisible({ timeout: 15000 }); + + await page.getByRole("tab", { name: "Consonants" }).click(); + + const alefInput = page.locator('input[aria-label="ALEF"]'); + await expect(alefInput).toBeVisible({ timeout: 10000 }); + await alefInput.fill("modified"); + await alefInput.blur(); + + await page.getByRole("tab", { name: "Schemas" }).click(); + + await expect(schemaSelect).toBeVisible({ timeout: 10000 }); + await schemaSelect.selectOption("SBL Academic"); + + await page.getByRole("tab", { name: "Consonants" }).click(); + + await expect(alefInput).toHaveValue("ʾ"); + }); + + test("after page reload, selected schema name persists", async ({ page }) => { + async function validateSchemaValue(stepName: string, value: string = "SBL Academic") { + await test.step(`Verification: ${stepName}`, async () => { + await expect(schemaSelect).toHaveValue(value); + }); + } + + const settingsBtn = page.getByTestId("settings-button"); + await settingsBtn.click(); + + const schemaSelect = page.getByTestId("schema-select"); + + await validateSchemaValue("Initial Load"); + + schemaSelect.selectOption("Tiberian"); + + await validateSchemaValue("After select change", "Tiberian"); + + await page.reload(); + await page.waitForLoadState("networkidle"); + + await settingsBtn.click(); + await expect(schemaSelect).toBeVisible({ timeout: 15000 }); + + await validateSchemaValue("After Reload", "Tiberian"); + }); + + test("after page reload, (user modified) suffix persists", async ({ page }) => { + const settingsBtn = page.getByTestId("settings-button"); + await settingsBtn.click(); + + const schemaSelect = page.getByTestId("schema-select"); + await expect(schemaSelect).toBeVisible({ timeout: 15000 }); + + schemaSelect.selectOption("Tiberian"); + + await page.getByRole("tab", { name: "Consonants" }).click(); + + const alefInput = page.locator('input[aria-label="ALEF"]'); + await expect(alefInput).toBeVisible({ timeout: 10000 }); + await alefInput.fill("custom-alef"); + await alefInput.blur(); + + await page.getByRole("tab", { name: "Schemas" }).click(); + + await expect(schemaSelect).toContainText("(user modified)"); + + await page.reload(); + await page.waitForLoadState("networkidle"); + + await settingsBtn.click(); + await expect(schemaSelect).toBeVisible({ timeout: 15000 }); + + await expect(schemaSelect).toContainText("Tiberian"); + await expect(schemaSelect).toContainText("(user modified)"); + }); + }); + + test.describe("Additional Features", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + await page.waitForLoadState("networkidle"); + }); + + test.afterEach(async ({ page }) => { + await page.evaluate(() => localStorage.clear()); + }); + + test("edit button opens feature editor without crashing after localStorage round-trip", async ({ + page, + }) => { + const settingsBtn = page.getByTestId("settings-button"); + await settingsBtn.click(); + + await page.getByRole("tab", { name: "Additional Features" }).click(); + + const featureCards = page.locator('[data-testid="edit-feature-button"]'); + const count = await featureCards.count(); + expect(count).toBeGreaterThan(0); + + await featureCards.first().click(); + + await expect(page.getByTestId("feature-type-select")).toBeVisible({ timeout: 5000 }); + await expect(page.getByTestId("hebrew-match-input")).toBeVisible({ timeout: 5000 }); + }); + + test("callback arrow function syntax preserved after localStorage round-trip", async ({ + page, + }) => { + const settingsBtn = page.getByTestId("settings-button"); + await settingsBtn.click(); + + await page.getByRole("tab", { name: "Additional Features" }).click(); + + await page.locator('[data-testid="edit-feature-button"]').first().click(); + + await expect(page.getByTestId("callback-editor")).toBeVisible({ timeout: 5000 }); + + const callbackEditor = page.locator('[data-testid="callback-editor"]'); + const callbackCodeBefore = await callbackEditor.locator(".cm-content").textContent(); + + expect(callbackCodeBefore).toContain("=>"); + expect(callbackCodeBefore).toContain("syllable"); + + await page.keyboard.press("Escape"); + + await page.reload(); + await page.waitForLoadState("networkidle"); + + await settingsBtn.click(); + + await page.getByRole("tab", { name: "Additional Features" }).click(); + + await page.locator('[data-testid="edit-feature-button"]').first().click(); + + await expect(page.getByTestId("callback-editor")).toBeVisible({ timeout: 5000 }); + + const callbackCodeAfter = await callbackEditor.locator(".cm-content").textContent(); + + expect(callbackCodeAfter).toContain("=>"); + expect(callbackCodeAfter).toContain("syllable"); + expect(callbackCodeAfter).toBe(callbackCodeBefore); + }); + + test("modified callback executes correctly after localStorage round-trip", async ({ page }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") { + errors.push(msg.text()); + } + }); + + const openAdditionalFeatures = async () => { + await page.getByTestId("settings-button").click(); + await page.getByRole("tab", { name: "Additional Features" }).click(); + }; + + const openFeatureEditor = async (state: "add" | "edit") => { + await openAdditionalFeatures(); + if (state === "add") { + await page.getByTestId("add-feature-button").click(); + } else { + // get the last editor, simulating the feature added before reload + await page + .getByTestId(/additional-feature-\d/) + .last() + .getByTestId("edit-feature-button") + .click(); + } + const editor = page.getByTestId("additional-feature-editor"); + await expect(editor).toBeVisible({ timeout: 1000 }); + await expect(editor).toHaveAttribute("data-formstate", state); + return editor; + }; + + // add a new feature to ensure test works regardless of schema used + const featureEditor = await openFeatureEditor("add"); + + await featureEditor.getByTestId("feature-type-select").selectOption("cluster"); + await featureEditor.getByTestId("hebrew-match-input").fill("א"); + + const callBackOption = featureEditor.locator(`button[data-value="callback"]`); + await callBackOption.click(); + await expect(featureEditor.getByTestId("callback-editor")).toBeVisible({ timeout: 5000 }); + + const callBackText = + "(cluster, hebrew) => { return cluster.text.length > 0 ? 'PROCESSED' : cluster.text; }"; + + // find the CodeMirror editor + const codeMirror = featureEditor.locator(".cm-content"); + + // make the editor active + await codeMirror.click(); + + // simulate a user entering a new function + await page.keyboard.press("ControlOrMeta+A"); + await page.keyboard.type(callBackText); + + // svelte-codemirror-editor debounces value updates by 300ms (handle_change). + // The debounce must fire and update Svelte state before save reads form.callback_code. + // This wait is necessary because programmatic CodeMirror edits bypass the normal + // input event flow that would otherwise trigger the binding synchronously. + // See: https://github.com/nicholasio/svelte-codemirror-editor + await page.waitForTimeout(500); + + await featureEditor.getByTestId("save-button").click(); + + await page.reload(); + await page.waitForLoadState("networkidle"); + + const secondFeatureEditor = await openFeatureEditor("edit"); + await expect(secondFeatureEditor.locator(".cm-content")).toHaveText(callBackText); + }); + + test("regex features display correctly after localStorage round-trip", async ({ page }) => { + async function verifyRegexText(stepName: string) { + await test.step(`Verification: ${stepName}`, async () => { + const settingsBtn = page.getByTestId("settings-button"); + await settingsBtn.click(); + await page.getByRole("tab", { name: "Additional Features" }).click(); + + const hebrew = page.getByTestId("additional-feature-hebrew-0"); + + expect(hebrew).not.toHaveText("[object Object]"); + expect(hebrew).not.toHaveText("object"); + + expect(hebrew).toHaveText("/[\\u{05B4}\\u{05BB}]/u"); + }); + } + + await verifyRegexText("Initial Load"); + + await page.reload(); + await page.waitForLoadState("networkidle"); + + await verifyRegexText("After Reload"); + + await page.reload(); + await page.waitForLoadState("networkidle"); + + // a bug was happening where an extra "\" was being added + await verifyRegexText("2nd Reload"); + }); + }); + + test.describe("ON_COMPLETE Callback", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + await page.waitForLoadState("networkidle"); + await page.evaluate(() => localStorage.clear()); + }); + + test.afterEach(async ({ page }) => { + await page.evaluate(() => localStorage.clear()); + }); + + test("toggle enables editor and code persists after reload", async ({ page }) => { + const settingsBtn = page.getByTestId("settings-button"); + await settingsBtn.click(); + await page.getByRole("tab", { name: "Others" }).click(); + + const toggle = page.locator("#enable-on-complete"); + await toggle.check(); + + const editor = page.getByTestId("on-complete-editor"); + await expect(editor).toBeVisible({ timeout: 5000 }); + + const cm = editor.locator(".cm-content"); + await cm.click(); + await page.keyboard.press("ControlOrMeta+A"); + await page.keyboard.type("(result, context) => { return result + ' [processed]'; }"); + + await page.waitForTimeout(500); + + await page.reload(); + await page.waitForLoadState("networkidle"); + + await settingsBtn.click(); + await page.getByRole("tab", { name: "Others" }).click(); + + await expect(page.getByTestId("on-complete-editor")).toBeVisible({ timeout: 5000 }); + + const cm2 = page.getByTestId("on-complete-editor").locator(".cm-content"); + await expect(cm2).toContainText("[processed]"); + }); + + test("disabling toggle removes ON_COMPLETE from schema", async ({ page }) => { + const settingsBtn = page.getByTestId("settings-button"); + await settingsBtn.click(); + await page.getByRole("tab", { name: "Others" }).click(); + + await page.locator("#enable-on-complete").check(); + await expect(page.getByTestId("on-complete-editor")).toBeVisible({ timeout: 5000 }); + + await page.locator("#enable-on-complete").uncheck(); + await expect(page.getByTestId("on-complete-editor")).not.toBeVisible(); + }); + }); + + test.describe("Ketiv/Qere Pairs", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + await page.waitForLoadState("networkidle"); + await page.evaluate(() => localStorage.clear()); + }); + + test.afterEach(async ({ page }) => { + await page.evaluate(() => localStorage.clear()); + }); + + async function open_syllabification_tab(page: import("@playwright/test").Page) { + await page.getByTestId("settings-button").click(); + await page.getByRole("tab", { name: "Syllabification" }).click(); + } + + test("pair does not disappear when typing input", async ({ page }) => { + await open_syllabification_tab(page); + + await page.getByRole("button", { name: "+ Add Pair" }).click(); + + const input = page.locator("#kq-input-0"); + await expect(input).toBeVisible({ timeout: 5000 }); + await input.fill("יהוה"); + + await expect(page.locator("#kq-input-0")).toHaveValue("יהוה"); + await expect(page.locator("#kq-output-0")).toBeVisible(); + }); + + test("output field does not echo input", async ({ page }) => { + await open_syllabification_tab(page); + + await page.getByRole("button", { name: "+ Add Pair" }).click(); + + const input = page.locator("#kq-input-0"); + await input.fill("יהוה"); + + const output = page.locator("#kq-output-0"); + await expect(output).toHaveValue(""); + }); + + test("string output is committed correctly", async ({ page }) => { + await open_syllabification_tab(page); + + await page.getByRole("button", { name: "+ Add Pair" }).click(); + + await page.locator("#kq-input-0").fill("שלום"); + await page.locator("#kq-output-0").fill("shalom"); + await page.locator("#kq-input-0").blur(); + + await expect(page.locator("#kq-output-0")).toHaveValue("shalom"); + }); + + test("round-trip persists pairs through reload", async ({ page }) => { + await open_syllabification_tab(page); + + await page.getByRole("button", { name: "+ Add Pair" }).click(); + await page.locator("#kq-input-0").fill("אלהים"); + await page.locator("#kq-output-0").fill("elohim"); + await page.locator("#kq-input-0").blur(); + + await page.reload(); + await page.waitForLoadState("networkidle"); + + await open_syllabification_tab(page); + + await expect(page.locator("#kq-input-0")).toHaveValue("אלהים"); + await expect(page.locator("#kq-output-0")).toHaveValue("elohim"); + }); + + test("callback output via KetivQereEditor", async ({ page }) => { + await open_syllabification_tab(page); + + await page.getByRole("button", { name: "+ Add Pair" }).click(); + await page.locator("#kq-input-0").fill("test"); + + await page.locator("#kq-out-callback-0").click(); + + await page.getByRole("button", { name: "Edit Callback" }).click(); + + const cm = page.getByTestId("kq-callback-editor").locator(".cm-content"); + await expect(cm).toBeVisible({ timeout: 5000 }); + await cm.click(); + await page.keyboard.press("ControlOrMeta+A"); + await page.keyboard.type("(text, input) => { return text.replace(input, 'REPLACED'); }"); + + await page.waitForTimeout(500); + + await page.getByTestId("kq-save-button").click(); + + await expect(page.getByText("(callback)")).toBeVisible(); + }); + + test("callback survives reload round-trip", async ({ page }) => { + await open_syllabification_tab(page); + + await page.getByRole("button", { name: "+ Add Pair" }).click(); + await page.locator("#kq-input-0").fill("/hello/"); + + await page.locator("#kq-out-callback-0").click(); + await page.getByRole("button", { name: "Edit Callback" }).click(); + + const cm = page.getByTestId("kq-callback-editor").locator(".cm-content"); + await expect(cm).toBeVisible({ timeout: 5000 }); + await cm.click(); + await page.keyboard.press("ControlOrMeta+A"); + await page.keyboard.type("(match, input) => match.toUpperCase()"); + + await page.waitForTimeout(500); + await page.getByTestId("kq-save-button").click(); + + await page.reload(); + await page.waitForLoadState("networkidle"); + await open_syllabification_tab(page); + + await page.locator("#kq-out-callback-0").click(); + await page.getByRole("button", { name: "Edit Callback" }).click(); + + const cm2 = page.getByTestId("kq-callback-editor").locator(".cm-content"); + await expect(cm2).toBeVisible({ timeout: 5000 }); + await expect(cm2).toContainText("toUpperCase"); + }); + + test("ignoreTaamim and captureTaamim checkboxes toggle correctly", async ({ page }) => { + await open_syllabification_tab(page); + + await page.getByRole("button", { name: "+ Add Pair" }).click(); + await page.locator("#kq-input-0").fill("test"); + + const ignoreBox = page.locator("#kq-ignore-0"); + const captureBox = page.locator("#kq-capture-0"); + + await expect(ignoreBox).toBeChecked(); + await expect(captureBox).not.toBeChecked(); + + await ignoreBox.uncheck(); + await captureBox.check(); + + await expect(ignoreBox).not.toBeChecked(); + await expect(captureBox).toBeChecked(); + }); + + test("deleting a pair removes it from the list", async ({ page }) => { + await open_syllabification_tab(page); + + await page.getByRole("button", { name: "+ Add Pair" }).click(); + await expect(page.locator("#kq-input-0")).toBeVisible(); + + await page.getByRole("button", { name: "Delete" }).click(); + await expect(page.locator("#kq-input-0")).not.toBeVisible(); + }); + + test("regex input is preserved after reload", async ({ page }) => { + await open_syllabification_tab(page); + + await page.getByRole("button", { name: "+ Add Pair" }).click(); + await page.locator("#kq-input-0").fill("/[\\u{05B4}]/u"); + await page.locator("#kq-output-0").fill("x"); + await page.locator("#kq-input-0").blur(); + + await page.reload(); + await page.waitForLoadState("networkidle"); + await open_syllabification_tab(page); + + await expect(page.locator("#kq-input-0")).toHaveValue("/[\\u{05B4}]/u"); + }); + }); +});