From f794f8f87cba5ba991865fb2cefd67f4c5d55f45 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Sat, 4 Jul 2026 22:05:50 +0200 Subject: [PATCH 1/4] Add live possible-duplicates check to the new-entry dialog Co-Authored-By: Claude Fable 5 --- .../lib/entry-editor/DuplicateCheck.svelte | 223 ++++++++++++++++++ .../lib/entry-editor/NewEntryDialog.svelte | 4 + .../lib/entry-editor/duplicate-check.test.ts | 137 +++++++++++ .../src/lib/entry-editor/duplicate-check.ts | 108 +++++++++ frontend/viewer/src/locales/en.po | 148 +++++++++++- frontend/viewer/src/locales/es.po | 148 +++++++++++- frontend/viewer/src/locales/fr.po | 148 +++++++++++- frontend/viewer/src/locales/id.po | 148 +++++++++++- frontend/viewer/src/locales/ko.po | 148 +++++++++++- frontend/viewer/src/locales/ms.po | 148 +++++++++++- frontend/viewer/src/locales/sw.po | 148 +++++++++++- frontend/viewer/src/locales/vi.po | 148 +++++++++++- .../viewer/tests/new-entry-duplicates.test.ts | 135 +++++++++++ 13 files changed, 1751 insertions(+), 40 deletions(-) create mode 100644 frontend/viewer/src/lib/entry-editor/DuplicateCheck.svelte create mode 100644 frontend/viewer/src/lib/entry-editor/duplicate-check.test.ts create mode 100644 frontend/viewer/src/lib/entry-editor/duplicate-check.ts create mode 100644 frontend/viewer/tests/new-entry-duplicates.test.ts diff --git a/frontend/viewer/src/lib/entry-editor/DuplicateCheck.svelte b/frontend/viewer/src/lib/entry-editor/DuplicateCheck.svelte new file mode 100644 index 0000000000..aa9f4238fe --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/DuplicateCheck.svelte @@ -0,0 +1,223 @@ + + +
+ {#if !matches?.length} + {#if duplicatesResource.loading || matches} +
+ {#if duplicatesResource.loading} + + {pt($t`Checking for similar entries…`, $t`Checking for similar words…`, viewService.currentView)} + {:else} + + {pt($t`No similar entries found`, $t`Looks like a new word`, viewService.currentView)} + {/if} +
+ {/if} + {:else} + userToggled = true} + class="rounded-md border border-amber-600/40 bg-amber-500/10 dark:border-amber-400/40" + > + + + + {#if hasExactWordMatch} + {pt($t`This entry may already exist`, $t`This word may already exist`, viewService.currentView)} + {:else} + {pt($t`Similar entries already exist`, $t`Similar words already exist`, viewService.currentView)} + {/if} + + {#if duplicatesResource.loading} + + {/if} + {matches.length}{duplicatesResource.current?.capped ? '+' : ''} + + + +
    + {#each displayedMatches as match (match.entry.id)} + {@const badge = kindLabel(match.kind)} + {@const goToLabel = pt($t`Go to entry`, $t`Go to word`, viewService.currentView)} + {@const headword = writingSystemService.headword(match.entry)} +
  • + + {#if canAddSense} + {@const addSenseLabel = pt($t`Add sense to this entry`, $t`Add meaning to this word`, viewService.currentView)} +
  • + {/each} + {#if matches.length > displayedMatches.length} + {@const remainingEntries = matches.length - displayedMatches.length} +
  • + +
  • + {/if} +
+
+
+ {/if} +
diff --git a/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte b/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte index 32f6b7515a..a88970132f 100644 --- a/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte +++ b/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte @@ -23,6 +23,7 @@ import {pt} from '$lib/views/view-text'; import * as Editor from '$lib/components/editor'; import Icon from '$lib/components/ui/icon/icon.svelte'; + import DuplicateCheck from './DuplicateCheck.svelte'; import EntryEditorPrimitive from './object-editors/EntryEditorPrimitive.svelte'; import ObjectHeader from './object-editors/ObjectHeader.svelte'; import SenseEditorPrimitive from './object-editors/SenseEditorPrimitive.svelte'; @@ -211,6 +212,9 @@ +
+ open = false} /> +
{#if errors.length}
diff --git a/frontend/viewer/src/lib/entry-editor/duplicate-check.test.ts b/frontend/viewer/src/lib/entry-editor/duplicate-check.test.ts new file mode 100644 index 0000000000..e0d1e71701 --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/duplicate-check.test.ts @@ -0,0 +1,137 @@ +import {describe, expect, it} from 'vitest'; +import type {IEntry} from '$lib/dotnet-types'; +import {classifyDuplicates, duplicateQueries, mergeSearchResults, normalizeForCompare} from './duplicate-check'; + +const vernWs = ['seh', 'por']; +const analysisWs = ['en', 'fr']; + +function makeEntry(partial: Partial): IEntry { + return { + id: crypto.randomUUID(), + lexemeForm: {}, + citationForm: {}, + senses: [], + ...partial, + } as IEntry; +} + +function withGloss(lexeme: string, gloss: string): IEntry { + return makeEntry({ + lexemeForm: {seh: lexeme}, + senses: [{gloss: {en: gloss}} as unknown as IEntry['senses'][number]], + }); +} + +describe('normalizeForCompare', () => { + it('ignores case and accents', () => { + expect(normalizeForCompare('Ñumbá ')).toBe('numba'); + expect(normalizeForCompare('CAFÉ')).toBe('cafe'); + }); +}); + +describe('duplicateQueries', () => { + it('collects distinct vernacular and gloss texts', () => { + const queries = duplicateQueries( + {lexemeForm: {seh: 'nyumba', por: 'casa'}, citationForm: {seh: 'nyumba'}}, + {gloss: {en: 'house', fr: ''}}, + vernWs, + analysisWs, + ); + expect(queries.vernacular).toEqual(['nyumba', 'casa']); + expect(queries.analysis).toEqual(['house']); + }); + + it('skips blank and too-short values', () => { + const queries = duplicateQueries( + {lexemeForm: {seh: ' n '}, citationForm: {}}, + {gloss: {en: ' '}}, + vernWs, + analysisWs, + ); + expect(queries.vernacular).toEqual([]); + expect(queries.analysis).toEqual([]); + }); + + it('measures the length threshold on the normalized text', () => { + // 'e' + combining acute is 2 chars raw but 1 char once marks are stripped + const queries = duplicateQueries( + {lexemeForm: {seh: 'é'}, citationForm: {}}, + undefined, + vernWs, + analysisWs, + ); + expect(queries.vernacular).toEqual([]); + }); + + it('dedupes values that only differ by case or accents', () => { + const queries = duplicateQueries( + {lexemeForm: {seh: 'café'}, citationForm: {seh: 'Cafe'}}, + undefined, + vernWs, + analysisWs, + ); + expect(queries.vernacular).toEqual(['café']); + }); + + it('ignores values in writing systems outside the given lists', () => { + const queries = duplicateQueries( + {lexemeForm: {'seh-Zxxx-x-audio': 'clip.wav', seh: 'nyumba'}, citationForm: {}}, + undefined, + vernWs, + analysisWs, + ); + expect(queries.vernacular).toEqual(['nyumba']); + }); +}); + +describe('mergeSearchResults', () => { + it('dedupes entries matched by multiple queries, preserving first-seen order', () => { + const a = makeEntry({}); + const b = makeEntry({}); + const c = makeEntry({}); + expect(mergeSearchResults([[a, b], [b, c, a]])).toEqual([a, b, c]); + }); +}); + +describe('classifyDuplicates', () => { + const queries = {vernacular: ['nyumba'], analysis: ['house']}; + + it('classifies exact headword matches as same-word, even via citation form or other ws', () => { + const byLexeme = makeEntry({lexemeForm: {seh: 'Nyumbá'}}); + const byCitation = makeEntry({citationForm: {por: 'nyumba'}}); + const result = classifyDuplicates([byLexeme, byCitation], queries, vernWs, analysisWs); + expect(result.map(m => m.kind)).toEqual(['same-word', 'same-word']); + }); + + it('classifies partial headword overlap (either direction) as similar-word', () => { + const superstring = makeEntry({lexemeForm: {seh: 'nyumbazi'}}); + const substring = makeEntry({lexemeForm: {seh: 'yumba'}}); + const result = classifyDuplicates([superstring, substring], queries, vernWs, analysisWs); + expect(result.map(m => m.kind)).toEqual(['similar-word', 'similar-word']); + }); + + it('classifies gloss overlap as same-meaning', () => { + const entry = withGloss('cabana', 'house'); + expect(classifyDuplicates([entry], queries, vernWs, analysisWs)[0].kind).toBe('same-meaning'); + }); + + it('falls back to related when nothing overlaps directly', () => { + const entry = withGloss('cabana', 'dwelling'); + expect(classifyDuplicates([entry], queries, vernWs, analysisWs)[0].kind).toBe('related'); + }); + + it('sorts word matches above meaning matches, preserving relevance order within a kind', () => { + const meaning = withGloss('cabana', 'house'); + const similarA = makeEntry({lexemeForm: {seh: 'nyumbazi'}}); + const similarB = makeEntry({lexemeForm: {seh: 'manyumba'}}); + const exact = makeEntry({lexemeForm: {seh: 'nyumba'}}); + const result = classifyDuplicates([meaning, similarA, similarB, exact], queries, vernWs, analysisWs); + expect(result.map(m => m.entry.id)).toEqual([exact.id, similarA.id, similarB.id, meaning.id]); + }); + + it('never reports same-word when no vernacular text was typed', () => { + const entry = makeEntry({lexemeForm: {seh: 'nyumba'}}); + const result = classifyDuplicates([entry], {vernacular: [], analysis: ['house']}, vernWs, analysisWs); + expect(result[0].kind).toBe('related'); + }); +}); diff --git a/frontend/viewer/src/lib/entry-editor/duplicate-check.ts b/frontend/viewer/src/lib/entry-editor/duplicate-check.ts new file mode 100644 index 0000000000..fa6ca20019 --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/duplicate-check.ts @@ -0,0 +1,108 @@ +import type {IEntry, ISense} from '$lib/dotnet-types'; + +export const MIN_QUERY_LENGTH = 2; + +/** + * Ordered strongest to weakest; `related` covers candidates the backend matched some other + * way (e.g. via definition text), which we still show but without a match badge. + */ +export type DuplicateMatchKind = 'same-word' | 'similar-word' | 'same-meaning' | 'related'; + +export interface DuplicateMatch { + entry: IEntry; + kind: DuplicateMatchKind; +} + +export interface DuplicateQueries { + /** Texts the user typed into vernacular fields (lexeme form, citation form) */ + vernacular: string[]; + /** Texts the user typed into gloss fields */ + analysis: string[]; +} + +const kindRank: Record = { + 'same-word': 0, + 'similar-word': 1, + 'same-meaning': 2, + 'related': 3, +}; + +// mirrors the backend's ContainsIgnoreCaseAccents comparisons (SqlHelpers) +export function normalizeForCompare(value: string): string { + return value.normalize('NFD').replace(/\p{Mn}/gu, '').toLocaleLowerCase().trim(); +} + +function distinctQueries(values: (string | undefined)[]): string[] { + const seen = new Set(); + const queries: string[] = []; + for (const value of values) { + const trimmed = value?.trim(); + if (!trimmed) continue; + const normalized = normalizeForCompare(trimmed); + if (normalized.length < MIN_QUERY_LENGTH || seen.has(normalized)) continue; + seen.add(normalized); + queries.push(trimmed); + } + return queries; +} + +export function duplicateQueries( + entry: Pick, + sense: Pick | undefined, + vernacularWsIds: string[], + analysisWsIds: string[], +): DuplicateQueries { + return { + vernacular: distinctQueries(vernacularWsIds.flatMap(ws => [entry.lexemeForm?.[ws], entry.citationForm?.[ws]])), + analysis: distinctQueries(analysisWsIds.map(ws => sense?.gloss?.[ws])), + }; +} + +/** Merges per-query search results into a single relevance-ordered candidate list. */ +export function mergeSearchResults(results: IEntry[][]): IEntry[] { + const seen = new Set(); + return results.flat().filter(entry => { + if (seen.has(entry.id)) return false; + seen.add(entry.id); + return true; + }); +} + +/** + * Classifies search results against what the user typed and orders them strongest match first + * (headword matches before gloss matches). `candidates` are expected in search-relevance order, + * which is preserved within each kind. + */ +export function classifyDuplicates( + candidates: IEntry[], + queries: DuplicateQueries, + vernacularWsIds: string[], + analysisWsIds: string[], +): DuplicateMatch[] { + const vernQueries = queries.vernacular.map(normalizeForCompare); + const analysisQueries = queries.analysis.map(normalizeForCompare); + + function classify(entry: IEntry): DuplicateMatchKind { + const forms = vernacularWsIds.flatMap(ws => [entry.lexemeForm?.[ws], entry.citationForm?.[ws]]) + .filter((form): form is string => !!form) + .map(normalizeForCompare); + if (vernQueries.length) { + if (forms.some(form => vernQueries.includes(form))) return 'same-word'; + if (forms.some(form => vernQueries.some(query => form.includes(query) || query.includes(form)))) return 'similar-word'; + } + if (analysisQueries.length) { + const glosses = (entry.senses ?? []) + .flatMap(sense => analysisWsIds.map(ws => sense.gloss?.[ws])) + .filter((gloss): gloss is string => !!gloss) + .map(normalizeForCompare); + if (glosses.some(gloss => analysisQueries.some(query => gloss.includes(query) || query.includes(gloss)))) { + return 'same-meaning'; + } + } + return 'related'; + } + + return candidates + .map(entry => ({entry, kind: classify(entry)})) + .sort((a, b) => kindRank[a.kind] - kindRank[b.kind]); +} diff --git a/frontend/viewer/src/locales/en.po b/frontend/viewer/src/locales/en.po index b420b6f859..3947dd229d 100644 --- a/frontend/viewer/src/locales/en.po +++ b/frontend/viewer/src/locales/en.po @@ -156,6 +156,13 @@ msgstr "Add Example" msgid "Add Meaning" msgstr "Add Meaning" +#. Relevant view: Lite +#. Classic view equivalent: "Add sense to this entry" +#. Button on a possible-duplicate result in the New Word dialog: saves the meaning being typed onto that existing word (instead of creating a duplicate) and opens it +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add meaning to this word" +msgstr "Add meaning to this word" + #. Button in toolbar #: src/lib/entry-editor/NewEntryButton.svelte msgid "Add new word" @@ -176,6 +183,13 @@ msgstr "Add part of" msgid "Add Sense" msgstr "Add Sense" +#. Relevant view: Classic +#. Lite view equivalent: "Add meaning to this word" +#. Button on a possible-duplicate result in the New Entry dialog: saves the sense being typed onto that existing entry (instead of creating a duplicate) and opens it +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add sense to this entry" +msgstr "Add sense to this entry" + #. Subtitle of a placeholder row shown at the end of the entry list while searching (also in the no-results state) #. The search text is displayed above it as a headword; clicking opens the new-entry dialog pre-filled with that text #: src/project/browse/EntriesList.svelte @@ -364,6 +378,20 @@ msgstr "Changes can also be the result of fields being added to new versions of msgid "Changing the same field twice, for example, can result in two commits but only one change that needs to be applied to FieldWorks Classic. Commits can also consist of multiple changes." msgstr "Changing the same field twice, for example, can result in two commits but only one change that needs to be applied to FieldWorks Classic. Commits can also consist of multiple changes." +#. Relevant view: Classic +#. Lite view equivalent: "Checking for similar words…" +#. Status line in the New Entry dialog while searching for possible duplicate entries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Checking for similar entries…" +msgstr "Checking for similar entries…" + +#. Relevant view: Lite +#. Classic view equivalent: "Checking for similar entries…" +#. Status line in the New Word dialog while searching for possible duplicate words +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Checking for similar words…" +msgstr "Checking for similar words…" + #. Status message while checking for updates #: src/lib/updates/UpdateDialogContent.svelte msgid "Checking for updates..." @@ -900,7 +928,7 @@ msgstr "FieldWorks Lite version" #. Alt text for FieldWorks logo image #: src/home/HomeView.svelte -#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/AuthorLabel.svelte #: src/lib/components/OpenInFieldWorksButton.svelte #: src/project/ProjectDropdown.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte @@ -1039,6 +1067,20 @@ msgstr "Gloss" msgid "Go to {0}" msgstr "Go to {0}" +#. Relevant view: Classic +#. Lite view equivalent: "Go to word" +#. Tooltip on a possible-duplicate result in the New Entry dialog; clicking closes the dialog and opens that entry +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Go to entry" +msgstr "Go to entry" + +#. Relevant view: Lite +#. Classic view equivalent: "Go to entry" +#. Tooltip on a possible-duplicate result in the New Word dialog; clicking closes the dialog and opens that word +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Go to word" +msgstr "Go to word" + #. Filter option label #. Relevant view: Classic #. Lite view equivalent: "Part of speech" @@ -1227,6 +1269,13 @@ msgstr "Login to see projects" msgid "Logout" msgstr "Logout" +#. Relevant view: Lite +#. Classic view equivalent: "No similar entries found" +#. Reassuring status line in the New Word dialog: the duplicate check found no existing words like the one being typed +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Looks like a new word" +msgstr "Looks like a new word" + #. Footer text in the project sidebar. The emoji flags are the countries of origin of the development team — do not translate. #: src/project/ProjectSidebar.svelte msgid "Made with ❤️ from 🇦🇹 🇹🇭 🇺🇸" @@ -1272,6 +1321,13 @@ msgstr "Manual update is required. Please follow the instructions provided." msgid "Meaning" msgstr "Meaning" +#. Relevant view: Lite +#. Classic view equivalent: "Sense added to \"{0}\"" +#. Success notification after adding the typed meaning to an existing word; {0} is that word's headword +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Meaning added to \"{0}\"" +msgstr "Meaning added to \"{0}\"" + #. Task subject label in the task list. {0} = writing system abbreviation (e.g., "en", "fr"). Identifies entries lacking a definition in that writing system. #: src/project/tasks/tasks-service.ts msgid "Missing Definition {0}" @@ -1452,6 +1508,13 @@ msgstr "No server" msgid "No server configured" msgstr "No server configured" +#. Relevant view: Classic +#. Lite view equivalent: "Looks like a new word" +#. Status line in the New Entry dialog: the duplicate check found no existing entries like the one being typed +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "No similar entries found" +msgstr "No similar entries found" + #. Error message in the task editing drawer when no subject entity can be found. {0} = subject type (e.g., "sense", "example-sentence"). #: src/project/tasks/SubjectPopup.svelte msgid "No subject, unable to create a new {0}" @@ -1741,6 +1804,20 @@ msgstr "Review" msgid "rose" msgstr "rose" +#. Relevant view: Classic +#. Lite view equivalent: "Same word" +#. Badge on a possible-duplicate result: its headword is identical to the one being typed in the New Entry dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Same headword" +msgstr "Same headword" + +#. Relevant view: Lite +#. Classic view equivalent: "Same headword" +#. Badge on a possible-duplicate result: it is spelled exactly like the word being typed in the New Word dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Same word" +msgstr "Same word" + #. Dialog button to save audio with new name #: src/lib/components/field-editors/audio-input.svelte msgid "Save As" @@ -1827,6 +1904,13 @@ msgstr "Send us a message" msgid "Sense" msgstr "Sense" +#. Relevant view: Classic +#. Lite view equivalent: "Meaning added to \"{0}\"" +#. Success notification after adding the typed sense to an existing entry; {0} is that entry's headword +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Sense added to \"{0}\"" +msgstr "Sense added to \"{0}\"" + #. Field label for example text #. The sentence/phrase text for an example (source text in original language) #. Paired with "Translation" field for translated version @@ -1865,10 +1949,53 @@ msgid "Show" msgstr "Show" #. Expand button with count +#: src/lib/entry-editor/DuplicateCheck.svelte #: src/lib/entry-editor/EntryOrSensePicker.svelte msgid "Show {0} more..." msgstr "Show {0} more..." +#. Relevant view: Classic +#. Lite view equivalent: "Similar words already exist" +#. Warning banner in the New Entry dialog; expands to a list of possible duplicate entries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar entries already exist" +msgstr "Similar entries already exist" + +#. Relevant view: Classic +#. Lite view equivalent: "Similar meaning" +#. Badge on a possible-duplicate result: one of its glosses matches the gloss being typed in the New Entry dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar gloss" +msgstr "Similar gloss" + +#. Relevant view: Classic +#. Lite view equivalent: "Similar word" +#. Badge on a possible-duplicate result: its headword partly matches the one being typed in the New Entry dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar headword" +msgstr "Similar headword" + +#. Relevant view: Lite +#. Classic view equivalent: "Similar gloss" +#. Badge on a possible-duplicate result: one of its meanings matches the meaning being typed in the New Word dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar meaning" +msgstr "Similar meaning" + +#. Relevant view: Lite +#. Classic view equivalent: "Similar headword" +#. Badge on a possible-duplicate result: it is spelled almost like the word being typed in the New Word dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar word" +msgstr "Similar word" + +#. Relevant view: Lite +#. Classic view equivalent: "Similar entries already exist" +#. Warning banner in the New Word dialog; expands to a list of possible duplicate words +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar words already exist" +msgstr "Similar words already exist" + #. View option #: src/project/browse/EntryListViewOptions.svelte msgid "Simple" @@ -2062,6 +2189,13 @@ msgstr "This {0} was deleted" msgid "This date # and this emoji # are snippets" msgstr "This date # and this emoji # are snippets" +#. Relevant view: Classic +#. Lite view equivalent: "This word may already exist" +#. Warning banner in the New Entry dialog when an entry with the exact same headword exists; expands to a list of possible duplicates +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "This entry may already exist" +msgstr "This entry may already exist" + #. Help message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." @@ -2077,6 +2211,13 @@ msgstr "This task is complete" msgid "This will synchronize the FieldWorks Lite and FieldWorks Classic copies of your project in Lexbox." msgstr "This will synchronize the FieldWorks Lite and FieldWorks Classic copies of your project in Lexbox." +#. Relevant view: Lite +#. Classic view equivalent: "This entry may already exist" +#. Warning banner in the New Word dialog when a word with the exact same spelling exists; expands to a list of possible duplicates +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "This word may already exist" +msgstr "This word may already exist" + #. Button to show/hide filter panel #: src/project/browse/SearchFilter.svelte msgid "Toggle filters" @@ -2142,10 +2283,7 @@ msgid "Unable to open in FieldWorks" msgstr "Unable to open in FieldWorks" #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). -#: src/lib/activity/ActivityFilter.svelte -#: src/lib/activity/ActivityFilter.svelte -#: src/lib/activity/ActivityItem.svelte -#: src/lib/activity/ActivityView.svelte +#: src/lib/activity/AuthorLabel.svelte #: src/lib/activity/utils.ts #: src/project/sync/FwLiteToFwMergeDetails.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte diff --git a/frontend/viewer/src/locales/es.po b/frontend/viewer/src/locales/es.po index e4049b6109..ba009357e5 100644 --- a/frontend/viewer/src/locales/es.po +++ b/frontend/viewer/src/locales/es.po @@ -161,6 +161,13 @@ msgstr "Añadir ejemplo" msgid "Add Meaning" msgstr "Añadir significado" +#. Relevant view: Lite +#. Classic view equivalent: "Add sense to this entry" +#. Button on a possible-duplicate result in the New Word dialog: saves the meaning being typed onto that existing word (instead of creating a duplicate) and opens it +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add meaning to this word" +msgstr "" + #. Button in toolbar #: src/lib/entry-editor/NewEntryButton.svelte msgid "Add new word" @@ -181,6 +188,13 @@ msgstr "Añadir parte de" msgid "Add Sense" msgstr "Añadir acepción" +#. Relevant view: Classic +#. Lite view equivalent: "Add meaning to this word" +#. Button on a possible-duplicate result in the New Entry dialog: saves the sense being typed onto that existing entry (instead of creating a duplicate) and opens it +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add sense to this entry" +msgstr "" + #. Subtitle of a placeholder row shown at the end of the entry list while searching (also in the no-results state) #. The search text is displayed above it as a headword; clicking opens the new-entry dialog pre-filled with that text #: src/project/browse/EntriesList.svelte @@ -369,6 +383,20 @@ msgstr "Los cambios también pueden ser el resultado de que los campos sean agre msgid "Changing the same field twice, for example, can result in two commits but only one change that needs to be applied to FieldWorks Classic. Commits can also consist of multiple changes." msgstr "Cambiar el mismo campo dos veces, por ejemplo, puede resultar en dos commits pero sólo un cambio que necesita ser aplicado a FieldWorks Classic. Los commits también pueden consistir en varios cambios." +#. Relevant view: Classic +#. Lite view equivalent: "Checking for similar words…" +#. Status line in the New Entry dialog while searching for possible duplicate entries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Checking for similar entries…" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Checking for similar entries…" +#. Status line in the New Word dialog while searching for possible duplicate words +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Checking for similar words…" +msgstr "" + #. Status message while checking for updates #: src/lib/updates/UpdateDialogContent.svelte msgid "Checking for updates..." @@ -905,7 +933,7 @@ msgstr "Versión de FieldWorks Lite" #. Alt text for FieldWorks logo image #: src/home/HomeView.svelte -#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/AuthorLabel.svelte #: src/lib/components/OpenInFieldWorksButton.svelte #: src/project/ProjectDropdown.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte @@ -1044,6 +1072,20 @@ msgstr "Glosa" msgid "Go to {0}" msgstr "Visite {0}" +#. Relevant view: Classic +#. Lite view equivalent: "Go to word" +#. Tooltip on a possible-duplicate result in the New Entry dialog; clicking closes the dialog and opens that entry +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Go to entry" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Go to entry" +#. Tooltip on a possible-duplicate result in the New Word dialog; clicking closes the dialog and opens that word +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Go to word" +msgstr "" + #. Filter option label #. Relevant view: Classic #. Lite view equivalent: "Part of speech" @@ -1232,6 +1274,13 @@ msgstr "Inicie sesión para ver los proyectos" msgid "Logout" msgstr "Cierre de sesión" +#. Relevant view: Lite +#. Classic view equivalent: "No similar entries found" +#. Reassuring status line in the New Word dialog: the duplicate check found no existing words like the one being typed +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Looks like a new word" +msgstr "" + #. Footer text in the project sidebar. The emoji flags are the countries of origin of the development team — do not translate. #: src/project/ProjectSidebar.svelte msgid "Made with ❤️ from 🇦🇹 🇹🇭 🇺🇸" @@ -1277,6 +1326,13 @@ msgstr "Se requiere una actualización manual. Siga las instrucciones proporcion msgid "Meaning" msgstr "Significado" +#. Relevant view: Lite +#. Classic view equivalent: "Sense added to \"{0}\"" +#. Success notification after adding the typed meaning to an existing word; {0} is that word's headword +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Meaning added to \"{0}\"" +msgstr "" + #. Task subject label in the task list. {0} = writing system abbreviation (e.g., "en", "fr"). Identifies entries lacking a definition in that writing system. #: src/project/tasks/tasks-service.ts msgid "Missing Definition {0}" @@ -1457,6 +1513,13 @@ msgstr "Sin servidor" msgid "No server configured" msgstr "Ningún servidor configurado" +#. Relevant view: Classic +#. Lite view equivalent: "Looks like a new word" +#. Status line in the New Entry dialog: the duplicate check found no existing entries like the one being typed +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "No similar entries found" +msgstr "" + #. Error message in the task editing drawer when no subject entity can be found. {0} = subject type (e.g., "sense", "example-sentence"). #: src/project/tasks/SubjectPopup.svelte msgid "No subject, unable to create a new {0}" @@ -1746,6 +1809,20 @@ msgstr "Revisión" msgid "rose" msgstr "rosa" +#. Relevant view: Classic +#. Lite view equivalent: "Same word" +#. Badge on a possible-duplicate result: its headword is identical to the one being typed in the New Entry dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Same headword" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Same headword" +#. Badge on a possible-duplicate result: it is spelled exactly like the word being typed in the New Word dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Same word" +msgstr "" + #. Dialog button to save audio with new name #: src/lib/components/field-editors/audio-input.svelte msgid "Save As" @@ -1832,6 +1909,13 @@ msgstr "Envíenos un mensaje" msgid "Sense" msgstr "Acepción" +#. Relevant view: Classic +#. Lite view equivalent: "Meaning added to \"{0}\"" +#. Success notification after adding the typed sense to an existing entry; {0} is that entry's headword +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Sense added to \"{0}\"" +msgstr "" + #. Field label for example text #. The sentence/phrase text for an example (source text in original language) #. Paired with "Translation" field for translated version @@ -1870,10 +1954,53 @@ msgid "Show" msgstr "Mostrar" #. Expand button with count +#: src/lib/entry-editor/DuplicateCheck.svelte #: src/lib/entry-editor/EntryOrSensePicker.svelte msgid "Show {0} more..." msgstr "Mostrar {0} más..." +#. Relevant view: Classic +#. Lite view equivalent: "Similar words already exist" +#. Warning banner in the New Entry dialog; expands to a list of possible duplicate entries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar entries already exist" +msgstr "" + +#. Relevant view: Classic +#. Lite view equivalent: "Similar meaning" +#. Badge on a possible-duplicate result: one of its glosses matches the gloss being typed in the New Entry dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar gloss" +msgstr "" + +#. Relevant view: Classic +#. Lite view equivalent: "Similar word" +#. Badge on a possible-duplicate result: its headword partly matches the one being typed in the New Entry dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar headword" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Similar gloss" +#. Badge on a possible-duplicate result: one of its meanings matches the meaning being typed in the New Word dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar meaning" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Similar headword" +#. Badge on a possible-duplicate result: it is spelled almost like the word being typed in the New Word dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar word" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Similar entries already exist" +#. Warning banner in the New Word dialog; expands to a list of possible duplicate words +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar words already exist" +msgstr "" + #. View option #: src/project/browse/EntryListViewOptions.svelte msgid "Simple" @@ -2067,6 +2194,13 @@ msgstr "Este {0} ha sido eliminado" msgid "This date # and this emoji # are snippets" msgstr "Esta fecha # y este emoji # son fragmentos" +#. Relevant view: Classic +#. Lite view equivalent: "This word may already exist" +#. Warning banner in the New Entry dialog when an entry with the exact same headword exists; expands to a list of possible duplicates +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "This entry may already exist" +msgstr "" + #. Help message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." @@ -2082,6 +2216,13 @@ msgstr "Esta tarea está completa" msgid "This will synchronize the FieldWorks Lite and FieldWorks Classic copies of your project in Lexbox." msgstr "Esto sincronizará las copias de FieldWorks Lite y FieldWorks Classic de su proyecto en Lexbox." +#. Relevant view: Lite +#. Classic view equivalent: "This entry may already exist" +#. Warning banner in the New Word dialog when a word with the exact same spelling exists; expands to a list of possible duplicates +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "This word may already exist" +msgstr "" + #. Button to show/hide filter panel #: src/project/browse/SearchFilter.svelte msgid "Toggle filters" @@ -2147,10 +2288,7 @@ msgid "Unable to open in FieldWorks" msgstr "No se puede abrir en FieldWorks" #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). -#: src/lib/activity/ActivityFilter.svelte -#: src/lib/activity/ActivityFilter.svelte -#: src/lib/activity/ActivityItem.svelte -#: src/lib/activity/ActivityView.svelte +#: src/lib/activity/AuthorLabel.svelte #: src/lib/activity/utils.ts #: src/project/sync/FwLiteToFwMergeDetails.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte diff --git a/frontend/viewer/src/locales/fr.po b/frontend/viewer/src/locales/fr.po index 13b01dd9d2..4999f369ce 100644 --- a/frontend/viewer/src/locales/fr.po +++ b/frontend/viewer/src/locales/fr.po @@ -161,6 +161,13 @@ msgstr "Ajouter un exemple" msgid "Add Meaning" msgstr "Ajouter une signification" +#. Relevant view: Lite +#. Classic view equivalent: "Add sense to this entry" +#. Button on a possible-duplicate result in the New Word dialog: saves the meaning being typed onto that existing word (instead of creating a duplicate) and opens it +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add meaning to this word" +msgstr "" + #. Button in toolbar #: src/lib/entry-editor/NewEntryButton.svelte msgid "Add new word" @@ -181,6 +188,13 @@ msgstr "Ajouter une partie de" msgid "Add Sense" msgstr "Ajouter du sens" +#. Relevant view: Classic +#. Lite view equivalent: "Add meaning to this word" +#. Button on a possible-duplicate result in the New Entry dialog: saves the sense being typed onto that existing entry (instead of creating a duplicate) and opens it +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add sense to this entry" +msgstr "" + #. Subtitle of a placeholder row shown at the end of the entry list while searching (also in the no-results state) #. The search text is displayed above it as a headword; clicking opens the new-entry dialog pre-filled with that text #: src/project/browse/EntriesList.svelte @@ -369,6 +383,20 @@ msgstr "Les modifications peuvent également résulter de l'ajout de champs à d msgid "Changing the same field twice, for example, can result in two commits but only one change that needs to be applied to FieldWorks Classic. Commits can also consist of multiple changes." msgstr "Changer le même champ deux fois, par exemple, peut entraîner deux livraisons mais un seul changement qui doit être appliqué à FieldWorks Classic. Les commits peuvent également comporter de multiples changements." +#. Relevant view: Classic +#. Lite view equivalent: "Checking for similar words…" +#. Status line in the New Entry dialog while searching for possible duplicate entries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Checking for similar entries…" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Checking for similar entries…" +#. Status line in the New Word dialog while searching for possible duplicate words +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Checking for similar words…" +msgstr "" + #. Status message while checking for updates #: src/lib/updates/UpdateDialogContent.svelte msgid "Checking for updates..." @@ -905,7 +933,7 @@ msgstr "Version de FieldWorks Lite" #. Alt text for FieldWorks logo image #: src/home/HomeView.svelte -#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/AuthorLabel.svelte #: src/lib/components/OpenInFieldWorksButton.svelte #: src/project/ProjectDropdown.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte @@ -1044,6 +1072,20 @@ msgstr "Glose" msgid "Go to {0}" msgstr "Naviguer à {0}" +#. Relevant view: Classic +#. Lite view equivalent: "Go to word" +#. Tooltip on a possible-duplicate result in the New Entry dialog; clicking closes the dialog and opens that entry +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Go to entry" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Go to entry" +#. Tooltip on a possible-duplicate result in the New Word dialog; clicking closes the dialog and opens that word +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Go to word" +msgstr "" + #. Filter option label #. Relevant view: Classic #. Lite view equivalent: "Part of speech" @@ -1232,6 +1274,13 @@ msgstr "Se connecter pour voir les projets" msgid "Logout" msgstr "Déconnexion" +#. Relevant view: Lite +#. Classic view equivalent: "No similar entries found" +#. Reassuring status line in the New Word dialog: the duplicate check found no existing words like the one being typed +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Looks like a new word" +msgstr "" + #. Footer text in the project sidebar. The emoji flags are the countries of origin of the development team — do not translate. #: src/project/ProjectSidebar.svelte msgid "Made with ❤️ from 🇦🇹 🇹🇭 🇺🇸" @@ -1277,6 +1326,13 @@ msgstr "Une mise à jour manuelle est requise. Veuillez suivre les instructions msgid "Meaning" msgstr "Signification" +#. Relevant view: Lite +#. Classic view equivalent: "Sense added to \"{0}\"" +#. Success notification after adding the typed meaning to an existing word; {0} is that word's headword +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Meaning added to \"{0}\"" +msgstr "" + #. Task subject label in the task list. {0} = writing system abbreviation (e.g., "en", "fr"). Identifies entries lacking a definition in that writing system. #: src/project/tasks/tasks-service.ts msgid "Missing Definition {0}" @@ -1457,6 +1513,13 @@ msgstr "Aucun serveur" msgid "No server configured" msgstr "Pas de serveur configuré" +#. Relevant view: Classic +#. Lite view equivalent: "Looks like a new word" +#. Status line in the New Entry dialog: the duplicate check found no existing entries like the one being typed +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "No similar entries found" +msgstr "" + #. Error message in the task editing drawer when no subject entity can be found. {0} = subject type (e.g., "sense", "example-sentence"). #: src/project/tasks/SubjectPopup.svelte msgid "No subject, unable to create a new {0}" @@ -1746,6 +1809,20 @@ msgstr "Évaluer" msgid "rose" msgstr "rose" +#. Relevant view: Classic +#. Lite view equivalent: "Same word" +#. Badge on a possible-duplicate result: its headword is identical to the one being typed in the New Entry dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Same headword" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Same headword" +#. Badge on a possible-duplicate result: it is spelled exactly like the word being typed in the New Word dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Same word" +msgstr "" + #. Dialog button to save audio with new name #: src/lib/components/field-editors/audio-input.svelte msgid "Save As" @@ -1832,6 +1909,13 @@ msgstr "Envoyez-nous un message" msgid "Sense" msgstr "Sens" +#. Relevant view: Classic +#. Lite view equivalent: "Meaning added to \"{0}\"" +#. Success notification after adding the typed sense to an existing entry; {0} is that entry's headword +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Sense added to \"{0}\"" +msgstr "" + #. Field label for example text #. The sentence/phrase text for an example (source text in original language) #. Paired with "Translation" field for translated version @@ -1870,10 +1954,53 @@ msgid "Show" msgstr "Afficher" #. Expand button with count +#: src/lib/entry-editor/DuplicateCheck.svelte #: src/lib/entry-editor/EntryOrSensePicker.svelte msgid "Show {0} more..." msgstr "Afficher {0} plus..." +#. Relevant view: Classic +#. Lite view equivalent: "Similar words already exist" +#. Warning banner in the New Entry dialog; expands to a list of possible duplicate entries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar entries already exist" +msgstr "" + +#. Relevant view: Classic +#. Lite view equivalent: "Similar meaning" +#. Badge on a possible-duplicate result: one of its glosses matches the gloss being typed in the New Entry dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar gloss" +msgstr "" + +#. Relevant view: Classic +#. Lite view equivalent: "Similar word" +#. Badge on a possible-duplicate result: its headword partly matches the one being typed in the New Entry dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar headword" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Similar gloss" +#. Badge on a possible-duplicate result: one of its meanings matches the meaning being typed in the New Word dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar meaning" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Similar headword" +#. Badge on a possible-duplicate result: it is spelled almost like the word being typed in the New Word dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar word" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Similar entries already exist" +#. Warning banner in the New Word dialog; expands to a list of possible duplicate words +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar words already exist" +msgstr "" + #. View option #: src/project/browse/EntryListViewOptions.svelte msgid "Simple" @@ -2067,6 +2194,13 @@ msgstr "Cet {0} a été supprimé" msgid "This date # and this emoji # are snippets" msgstr "Cette date # et cet emoji # sont des extraits." +#. Relevant view: Classic +#. Lite view equivalent: "This word may already exist" +#. Warning banner in the New Entry dialog when an entry with the exact same headword exists; expands to a list of possible duplicates +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "This entry may already exist" +msgstr "" + #. Help message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." @@ -2082,6 +2216,13 @@ msgstr "Cette tâche est terminée" msgid "This will synchronize the FieldWorks Lite and FieldWorks Classic copies of your project in Lexbox." msgstr "Cela synchronisera les copies FieldWorks Lite et FieldWorks Classic de votre projet dans Lexbox." +#. Relevant view: Lite +#. Classic view equivalent: "This entry may already exist" +#. Warning banner in the New Word dialog when a word with the exact same spelling exists; expands to a list of possible duplicates +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "This word may already exist" +msgstr "" + #. Button to show/hide filter panel #: src/project/browse/SearchFilter.svelte msgid "Toggle filters" @@ -2147,10 +2288,7 @@ msgid "Unable to open in FieldWorks" msgstr "Impossible d'ouvrir FieldWorks" #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). -#: src/lib/activity/ActivityFilter.svelte -#: src/lib/activity/ActivityFilter.svelte -#: src/lib/activity/ActivityItem.svelte -#: src/lib/activity/ActivityView.svelte +#: src/lib/activity/AuthorLabel.svelte #: src/lib/activity/utils.ts #: src/project/sync/FwLiteToFwMergeDetails.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte diff --git a/frontend/viewer/src/locales/id.po b/frontend/viewer/src/locales/id.po index d749849c0f..43e40534e4 100644 --- a/frontend/viewer/src/locales/id.po +++ b/frontend/viewer/src/locales/id.po @@ -161,6 +161,13 @@ msgstr "Tambahkan Contoh" msgid "Add Meaning" msgstr "Tambahkan Makna" +#. Relevant view: Lite +#. Classic view equivalent: "Add sense to this entry" +#. Button on a possible-duplicate result in the New Word dialog: saves the meaning being typed onto that existing word (instead of creating a duplicate) and opens it +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add meaning to this word" +msgstr "" + #. Button in toolbar #: src/lib/entry-editor/NewEntryButton.svelte msgid "Add new word" @@ -181,6 +188,13 @@ msgstr "Tambahkan bagian dari" msgid "Add Sense" msgstr "Tambahkan Pengertian" +#. Relevant view: Classic +#. Lite view equivalent: "Add meaning to this word" +#. Button on a possible-duplicate result in the New Entry dialog: saves the sense being typed onto that existing entry (instead of creating a duplicate) and opens it +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add sense to this entry" +msgstr "" + #. Subtitle of a placeholder row shown at the end of the entry list while searching (also in the no-results state) #. The search text is displayed above it as a headword; clicking opens the new-entry dialog pre-filled with that text #: src/project/browse/EntriesList.svelte @@ -369,6 +383,20 @@ msgstr "Perubahan juga dapat terjadi karena adanya penambahan field pada versi b msgid "Changing the same field twice, for example, can result in two commits but only one change that needs to be applied to FieldWorks Classic. Commits can also consist of multiple changes." msgstr "Mengubah bidang yang sama dua kali, misalnya, dapat menghasilkan dua komit namun hanya satu perubahan yang perlu diterapkan ke FieldWorks Classic. Commit juga dapat terdiri dari beberapa perubahan." +#. Relevant view: Classic +#. Lite view equivalent: "Checking for similar words…" +#. Status line in the New Entry dialog while searching for possible duplicate entries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Checking for similar entries…" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Checking for similar entries…" +#. Status line in the New Word dialog while searching for possible duplicate words +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Checking for similar words…" +msgstr "" + #. Status message while checking for updates #: src/lib/updates/UpdateDialogContent.svelte msgid "Checking for updates..." @@ -905,7 +933,7 @@ msgstr "Versi FieldWorks Lite" #. Alt text for FieldWorks logo image #: src/home/HomeView.svelte -#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/AuthorLabel.svelte #: src/lib/components/OpenInFieldWorksButton.svelte #: src/project/ProjectDropdown.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte @@ -1044,6 +1072,20 @@ msgstr "Arti Singkat" msgid "Go to {0}" msgstr "Buka {0}" +#. Relevant view: Classic +#. Lite view equivalent: "Go to word" +#. Tooltip on a possible-duplicate result in the New Entry dialog; clicking closes the dialog and opens that entry +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Go to entry" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Go to entry" +#. Tooltip on a possible-duplicate result in the New Word dialog; clicking closes the dialog and opens that word +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Go to word" +msgstr "" + #. Filter option label #. Relevant view: Classic #. Lite view equivalent: "Part of speech" @@ -1232,6 +1274,13 @@ msgstr "Login untuk melihat proyek" msgid "Logout" msgstr "Keluar" +#. Relevant view: Lite +#. Classic view equivalent: "No similar entries found" +#. Reassuring status line in the New Word dialog: the duplicate check found no existing words like the one being typed +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Looks like a new word" +msgstr "" + #. Footer text in the project sidebar. The emoji flags are the countries of origin of the development team — do not translate. #: src/project/ProjectSidebar.svelte msgid "Made with ❤️ from 🇦🇹 🇹🇭 🇺🇸" @@ -1277,6 +1326,13 @@ msgstr "Diperlukan pembaruan manual. Ikuti petunjuk yang diberikan." msgid "Meaning" msgstr "Arti" +#. Relevant view: Lite +#. Classic view equivalent: "Sense added to \"{0}\"" +#. Success notification after adding the typed meaning to an existing word; {0} is that word's headword +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Meaning added to \"{0}\"" +msgstr "" + #. Task subject label in the task list. {0} = writing system abbreviation (e.g., "en", "fr"). Identifies entries lacking a definition in that writing system. #: src/project/tasks/tasks-service.ts msgid "Missing Definition {0}" @@ -1457,6 +1513,13 @@ msgstr "Tidak ada server" msgid "No server configured" msgstr "Tidak ada server yang dikonfigurasi" +#. Relevant view: Classic +#. Lite view equivalent: "Looks like a new word" +#. Status line in the New Entry dialog: the duplicate check found no existing entries like the one being typed +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "No similar entries found" +msgstr "" + #. Error message in the task editing drawer when no subject entity can be found. {0} = subject type (e.g., "sense", "example-sentence"). #: src/project/tasks/SubjectPopup.svelte msgid "No subject, unable to create a new {0}" @@ -1746,6 +1809,20 @@ msgstr "Ulasan" msgid "rose" msgstr "naik" +#. Relevant view: Classic +#. Lite view equivalent: "Same word" +#. Badge on a possible-duplicate result: its headword is identical to the one being typed in the New Entry dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Same headword" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Same headword" +#. Badge on a possible-duplicate result: it is spelled exactly like the word being typed in the New Word dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Same word" +msgstr "" + #. Dialog button to save audio with new name #: src/lib/components/field-editors/audio-input.svelte msgid "Save As" @@ -1832,6 +1909,13 @@ msgstr "Kirimkan pesan kepada kami" msgid "Sense" msgstr "Pengertian" +#. Relevant view: Classic +#. Lite view equivalent: "Meaning added to \"{0}\"" +#. Success notification after adding the typed sense to an existing entry; {0} is that entry's headword +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Sense added to \"{0}\"" +msgstr "" + #. Field label for example text #. The sentence/phrase text for an example (source text in original language) #. Paired with "Translation" field for translated version @@ -1870,10 +1954,53 @@ msgid "Show" msgstr "Tampilkan" #. Expand button with count +#: src/lib/entry-editor/DuplicateCheck.svelte #: src/lib/entry-editor/EntryOrSensePicker.svelte msgid "Show {0} more..." msgstr "Tampilkan {0} selengkapnya..." +#. Relevant view: Classic +#. Lite view equivalent: "Similar words already exist" +#. Warning banner in the New Entry dialog; expands to a list of possible duplicate entries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar entries already exist" +msgstr "" + +#. Relevant view: Classic +#. Lite view equivalent: "Similar meaning" +#. Badge on a possible-duplicate result: one of its glosses matches the gloss being typed in the New Entry dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar gloss" +msgstr "" + +#. Relevant view: Classic +#. Lite view equivalent: "Similar word" +#. Badge on a possible-duplicate result: its headword partly matches the one being typed in the New Entry dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar headword" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Similar gloss" +#. Badge on a possible-duplicate result: one of its meanings matches the meaning being typed in the New Word dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar meaning" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Similar headword" +#. Badge on a possible-duplicate result: it is spelled almost like the word being typed in the New Word dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar word" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Similar entries already exist" +#. Warning banner in the New Word dialog; expands to a list of possible duplicate words +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar words already exist" +msgstr "" + #. View option #: src/project/browse/EntryListViewOptions.svelte msgid "Simple" @@ -2067,6 +2194,13 @@ msgstr "{0} ini telah dihapus" msgid "This date # and this emoji # are snippets" msgstr "Tanggal ini # dan emoji ini # adalah cuplikan" +#. Relevant view: Classic +#. Lite view equivalent: "This word may already exist" +#. Warning banner in the New Entry dialog when an entry with the exact same headword exists; expands to a list of possible duplicates +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "This entry may already exist" +msgstr "" + #. Help message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." @@ -2082,6 +2216,13 @@ msgstr "Tugas ini selesai" msgid "This will synchronize the FieldWorks Lite and FieldWorks Classic copies of your project in Lexbox." msgstr "Ini akan menyinkronkan salinan FieldWorks Lite dan FieldWorks Classic dari proyek Anda di Lexbox." +#. Relevant view: Lite +#. Classic view equivalent: "This entry may already exist" +#. Warning banner in the New Word dialog when a word with the exact same spelling exists; expands to a list of possible duplicates +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "This word may already exist" +msgstr "" + #. Button to show/hide filter panel #: src/project/browse/SearchFilter.svelte msgid "Toggle filters" @@ -2147,10 +2288,7 @@ msgid "Unable to open in FieldWorks" msgstr "Tidak dapat dibuka di FieldWorks" #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). -#: src/lib/activity/ActivityFilter.svelte -#: src/lib/activity/ActivityFilter.svelte -#: src/lib/activity/ActivityItem.svelte -#: src/lib/activity/ActivityView.svelte +#: src/lib/activity/AuthorLabel.svelte #: src/lib/activity/utils.ts #: src/project/sync/FwLiteToFwMergeDetails.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte diff --git a/frontend/viewer/src/locales/ko.po b/frontend/viewer/src/locales/ko.po index cddd9828d6..369b867672 100644 --- a/frontend/viewer/src/locales/ko.po +++ b/frontend/viewer/src/locales/ko.po @@ -161,6 +161,13 @@ msgstr "예제 추가" msgid "Add Meaning" msgstr "의미 추가" +#. Relevant view: Lite +#. Classic view equivalent: "Add sense to this entry" +#. Button on a possible-duplicate result in the New Word dialog: saves the meaning being typed onto that existing word (instead of creating a duplicate) and opens it +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add meaning to this word" +msgstr "" + #. Button in toolbar #: src/lib/entry-editor/NewEntryButton.svelte msgid "Add new word" @@ -181,6 +188,13 @@ msgstr "의 일부를 추가합니다." msgid "Add Sense" msgstr "센스 추가" +#. Relevant view: Classic +#. Lite view equivalent: "Add meaning to this word" +#. Button on a possible-duplicate result in the New Entry dialog: saves the sense being typed onto that existing entry (instead of creating a duplicate) and opens it +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add sense to this entry" +msgstr "" + #. Subtitle of a placeholder row shown at the end of the entry list while searching (also in the no-results state) #. The search text is displayed above it as a headword; clicking opens the new-entry dialog pre-filled with that text #: src/project/browse/EntriesList.svelte @@ -369,6 +383,20 @@ msgstr "FieldWorks Lite의 새 버전에 필드가 추가되어 변경 사항이 msgid "Changing the same field twice, for example, can result in two commits but only one change that needs to be applied to FieldWorks Classic. Commits can also consist of multiple changes." msgstr "예를 들어 동일한 필드를 두 번 변경하면 두 개의 커밋이 발생하지만 FieldWorks Classic에 적용해야 하는 변경 사항은 하나만 발생할 수 있습니다. 커밋은 여러 변경 사항으로 구성될 수도 있습니다." +#. Relevant view: Classic +#. Lite view equivalent: "Checking for similar words…" +#. Status line in the New Entry dialog while searching for possible duplicate entries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Checking for similar entries…" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Checking for similar entries…" +#. Status line in the New Word dialog while searching for possible duplicate words +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Checking for similar words…" +msgstr "" + #. Status message while checking for updates #: src/lib/updates/UpdateDialogContent.svelte msgid "Checking for updates..." @@ -905,7 +933,7 @@ msgstr "FieldWorks Lite 버전" #. Alt text for FieldWorks logo image #: src/home/HomeView.svelte -#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/AuthorLabel.svelte #: src/lib/components/OpenInFieldWorksButton.svelte #: src/project/ProjectDropdown.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte @@ -1044,6 +1072,20 @@ msgstr "광택" msgid "Go to {0}" msgstr "{0}으로 이동" +#. Relevant view: Classic +#. Lite view equivalent: "Go to word" +#. Tooltip on a possible-duplicate result in the New Entry dialog; clicking closes the dialog and opens that entry +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Go to entry" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Go to entry" +#. Tooltip on a possible-duplicate result in the New Word dialog; clicking closes the dialog and opens that word +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Go to word" +msgstr "" + #. Filter option label #. Relevant view: Classic #. Lite view equivalent: "Part of speech" @@ -1232,6 +1274,13 @@ msgstr "로그인하여 프로젝트 보기" msgid "Logout" msgstr "로그아웃" +#. Relevant view: Lite +#. Classic view equivalent: "No similar entries found" +#. Reassuring status line in the New Word dialog: the duplicate check found no existing words like the one being typed +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Looks like a new word" +msgstr "" + #. Footer text in the project sidebar. The emoji flags are the countries of origin of the development team — do not translate. #: src/project/ProjectSidebar.svelte msgid "Made with ❤️ from 🇦🇹 🇹🇭 🇺🇸" @@ -1277,6 +1326,13 @@ msgstr "수동 업데이트가 필요합니다. 제공된 지침을 따르세요 msgid "Meaning" msgstr "의미" +#. Relevant view: Lite +#. Classic view equivalent: "Sense added to \"{0}\"" +#. Success notification after adding the typed meaning to an existing word; {0} is that word's headword +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Meaning added to \"{0}\"" +msgstr "" + #. Task subject label in the task list. {0} = writing system abbreviation (e.g., "en", "fr"). Identifies entries lacking a definition in that writing system. #: src/project/tasks/tasks-service.ts msgid "Missing Definition {0}" @@ -1457,6 +1513,13 @@ msgstr "서버 없음" msgid "No server configured" msgstr "구성된 서버 없음" +#. Relevant view: Classic +#. Lite view equivalent: "Looks like a new word" +#. Status line in the New Entry dialog: the duplicate check found no existing entries like the one being typed +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "No similar entries found" +msgstr "" + #. Error message in the task editing drawer when no subject entity can be found. {0} = subject type (e.g., "sense", "example-sentence"). #: src/project/tasks/SubjectPopup.svelte msgid "No subject, unable to create a new {0}" @@ -1746,6 +1809,20 @@ msgstr "검토" msgid "rose" msgstr "rose" +#. Relevant view: Classic +#. Lite view equivalent: "Same word" +#. Badge on a possible-duplicate result: its headword is identical to the one being typed in the New Entry dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Same headword" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Same headword" +#. Badge on a possible-duplicate result: it is spelled exactly like the word being typed in the New Word dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Same word" +msgstr "" + #. Dialog button to save audio with new name #: src/lib/components/field-editors/audio-input.svelte msgid "Save As" @@ -1832,6 +1909,13 @@ msgstr "메시지 보내기" msgid "Sense" msgstr "Sense" +#. Relevant view: Classic +#. Lite view equivalent: "Meaning added to \"{0}\"" +#. Success notification after adding the typed sense to an existing entry; {0} is that entry's headword +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Sense added to \"{0}\"" +msgstr "" + #. Field label for example text #. The sentence/phrase text for an example (source text in original language) #. Paired with "Translation" field for translated version @@ -1870,10 +1954,53 @@ msgid "Show" msgstr "표시" #. Expand button with count +#: src/lib/entry-editor/DuplicateCheck.svelte #: src/lib/entry-editor/EntryOrSensePicker.svelte msgid "Show {0} more..." msgstr "보기 {0} 더보기..." +#. Relevant view: Classic +#. Lite view equivalent: "Similar words already exist" +#. Warning banner in the New Entry dialog; expands to a list of possible duplicate entries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar entries already exist" +msgstr "" + +#. Relevant view: Classic +#. Lite view equivalent: "Similar meaning" +#. Badge on a possible-duplicate result: one of its glosses matches the gloss being typed in the New Entry dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar gloss" +msgstr "" + +#. Relevant view: Classic +#. Lite view equivalent: "Similar word" +#. Badge on a possible-duplicate result: its headword partly matches the one being typed in the New Entry dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar headword" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Similar gloss" +#. Badge on a possible-duplicate result: one of its meanings matches the meaning being typed in the New Word dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar meaning" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Similar headword" +#. Badge on a possible-duplicate result: it is spelled almost like the word being typed in the New Word dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar word" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Similar entries already exist" +#. Warning banner in the New Word dialog; expands to a list of possible duplicate words +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar words already exist" +msgstr "" + #. View option #: src/project/browse/EntryListViewOptions.svelte msgid "Simple" @@ -2067,6 +2194,13 @@ msgstr "이 {0} 삭제됨" msgid "This date # and this emoji # are snippets" msgstr "이 날짜 #와 이 이모티콘 #은 스니펫입니다." +#. Relevant view: Classic +#. Lite view equivalent: "This word may already exist" +#. Warning banner in the New Entry dialog when an entry with the exact same headword exists; expands to a list of possible duplicates +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "This entry may already exist" +msgstr "" + #. Help message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." @@ -2082,6 +2216,13 @@ msgstr "이 작업이 완료되었습니다." msgid "This will synchronize the FieldWorks Lite and FieldWorks Classic copies of your project in Lexbox." msgstr "이렇게 하면 렉스박스에서 프로젝트의 FieldWorks Lite 및 FieldWorks Classic 사본이 동기화됩니다." +#. Relevant view: Lite +#. Classic view equivalent: "This entry may already exist" +#. Warning banner in the New Word dialog when a word with the exact same spelling exists; expands to a list of possible duplicates +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "This word may already exist" +msgstr "" + #. Button to show/hide filter panel #: src/project/browse/SearchFilter.svelte msgid "Toggle filters" @@ -2147,10 +2288,7 @@ msgid "Unable to open in FieldWorks" msgstr "FieldWorks에서 열 수 없음" #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). -#: src/lib/activity/ActivityFilter.svelte -#: src/lib/activity/ActivityFilter.svelte -#: src/lib/activity/ActivityItem.svelte -#: src/lib/activity/ActivityView.svelte +#: src/lib/activity/AuthorLabel.svelte #: src/lib/activity/utils.ts #: src/project/sync/FwLiteToFwMergeDetails.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte diff --git a/frontend/viewer/src/locales/ms.po b/frontend/viewer/src/locales/ms.po index 5f0ed57320..817b6cdc61 100644 --- a/frontend/viewer/src/locales/ms.po +++ b/frontend/viewer/src/locales/ms.po @@ -161,6 +161,13 @@ msgstr "Tambah Contoh" msgid "Add Meaning" msgstr "Tambah Makna" +#. Relevant view: Lite +#. Classic view equivalent: "Add sense to this entry" +#. Button on a possible-duplicate result in the New Word dialog: saves the meaning being typed onto that existing word (instead of creating a duplicate) and opens it +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add meaning to this word" +msgstr "" + #. Button in toolbar #: src/lib/entry-editor/NewEntryButton.svelte msgid "Add new word" @@ -181,6 +188,13 @@ msgstr "Tambah sebahagian daripada" msgid "Add Sense" msgstr "Tambah Makna" +#. Relevant view: Classic +#. Lite view equivalent: "Add meaning to this word" +#. Button on a possible-duplicate result in the New Entry dialog: saves the sense being typed onto that existing entry (instead of creating a duplicate) and opens it +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add sense to this entry" +msgstr "" + #. Subtitle of a placeholder row shown at the end of the entry list while searching (also in the no-results state) #. The search text is displayed above it as a headword; clicking opens the new-entry dialog pre-filled with that text #: src/project/browse/EntriesList.svelte @@ -369,6 +383,20 @@ msgstr "Perubahan juga boleh menjadi hasil daripada medan yang ditambahkan ke ve msgid "Changing the same field twice, for example, can result in two commits but only one change that needs to be applied to FieldWorks Classic. Commits can also consist of multiple changes." msgstr "Mengubah medan yang sama dua kali, sebagai contoh, boleh menghasilkan dua komit tetapi hanya satu perubahan yang perlu digunakan pada FieldWorks Classic. Komit juga boleh terdiri daripada berbilang perubahan." +#. Relevant view: Classic +#. Lite view equivalent: "Checking for similar words…" +#. Status line in the New Entry dialog while searching for possible duplicate entries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Checking for similar entries…" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Checking for similar entries…" +#. Status line in the New Word dialog while searching for possible duplicate words +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Checking for similar words…" +msgstr "" + #. Status message while checking for updates #: src/lib/updates/UpdateDialogContent.svelte msgid "Checking for updates..." @@ -905,7 +933,7 @@ msgstr "Versi FieldWorks Lite" #. Alt text for FieldWorks logo image #: src/home/HomeView.svelte -#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/AuthorLabel.svelte #: src/lib/components/OpenInFieldWorksButton.svelte #: src/project/ProjectDropdown.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte @@ -1044,6 +1072,20 @@ msgstr "Glos" msgid "Go to {0}" msgstr "Pergi ke {0}" +#. Relevant view: Classic +#. Lite view equivalent: "Go to word" +#. Tooltip on a possible-duplicate result in the New Entry dialog; clicking closes the dialog and opens that entry +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Go to entry" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Go to entry" +#. Tooltip on a possible-duplicate result in the New Word dialog; clicking closes the dialog and opens that word +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Go to word" +msgstr "" + #. Filter option label #. Relevant view: Classic #. Lite view equivalent: "Part of speech" @@ -1232,6 +1274,13 @@ msgstr "Log masuk untuk melihat projek" msgid "Logout" msgstr "Log keluar" +#. Relevant view: Lite +#. Classic view equivalent: "No similar entries found" +#. Reassuring status line in the New Word dialog: the duplicate check found no existing words like the one being typed +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Looks like a new word" +msgstr "" + #. Footer text in the project sidebar. The emoji flags are the countries of origin of the development team — do not translate. #: src/project/ProjectSidebar.svelte msgid "Made with ❤️ from 🇦🇹 🇹🇭 🇺🇸" @@ -1277,6 +1326,13 @@ msgstr "Kemas kini manual diperlukan. Sila ikut arahan yang diberikan." msgid "Meaning" msgstr "Makna" +#. Relevant view: Lite +#. Classic view equivalent: "Sense added to \"{0}\"" +#. Success notification after adding the typed meaning to an existing word; {0} is that word's headword +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Meaning added to \"{0}\"" +msgstr "" + #. Task subject label in the task list. {0} = writing system abbreviation (e.g., "en", "fr"). Identifies entries lacking a definition in that writing system. #: src/project/tasks/tasks-service.ts msgid "Missing Definition {0}" @@ -1457,6 +1513,13 @@ msgstr "Tiada pelayan" msgid "No server configured" msgstr "Tiada pelayan dikonfigurasikan" +#. Relevant view: Classic +#. Lite view equivalent: "Looks like a new word" +#. Status line in the New Entry dialog: the duplicate check found no existing entries like the one being typed +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "No similar entries found" +msgstr "" + #. Error message in the task editing drawer when no subject entity can be found. {0} = subject type (e.g., "sense", "example-sentence"). #: src/project/tasks/SubjectPopup.svelte msgid "No subject, unable to create a new {0}" @@ -1746,6 +1809,20 @@ msgstr "Semak" msgid "rose" msgstr "merah jambu" +#. Relevant view: Classic +#. Lite view equivalent: "Same word" +#. Badge on a possible-duplicate result: its headword is identical to the one being typed in the New Entry dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Same headword" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Same headword" +#. Badge on a possible-duplicate result: it is spelled exactly like the word being typed in the New Word dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Same word" +msgstr "" + #. Dialog button to save audio with new name #: src/lib/components/field-editors/audio-input.svelte msgid "Save As" @@ -1832,6 +1909,13 @@ msgstr "Hantar kami mesej" msgid "Sense" msgstr "Makna" +#. Relevant view: Classic +#. Lite view equivalent: "Meaning added to \"{0}\"" +#. Success notification after adding the typed sense to an existing entry; {0} is that entry's headword +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Sense added to \"{0}\"" +msgstr "" + #. Field label for example text #. The sentence/phrase text for an example (source text in original language) #. Paired with "Translation" field for translated version @@ -1870,10 +1954,53 @@ msgid "Show" msgstr "Tunjukkan" #. Expand button with count +#: src/lib/entry-editor/DuplicateCheck.svelte #: src/lib/entry-editor/EntryOrSensePicker.svelte msgid "Show {0} more..." msgstr "Tunjukkan {0} lagi..." +#. Relevant view: Classic +#. Lite view equivalent: "Similar words already exist" +#. Warning banner in the New Entry dialog; expands to a list of possible duplicate entries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar entries already exist" +msgstr "" + +#. Relevant view: Classic +#. Lite view equivalent: "Similar meaning" +#. Badge on a possible-duplicate result: one of its glosses matches the gloss being typed in the New Entry dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar gloss" +msgstr "" + +#. Relevant view: Classic +#. Lite view equivalent: "Similar word" +#. Badge on a possible-duplicate result: its headword partly matches the one being typed in the New Entry dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar headword" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Similar gloss" +#. Badge on a possible-duplicate result: one of its meanings matches the meaning being typed in the New Word dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar meaning" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Similar headword" +#. Badge on a possible-duplicate result: it is spelled almost like the word being typed in the New Word dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar word" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Similar entries already exist" +#. Warning banner in the New Word dialog; expands to a list of possible duplicate words +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar words already exist" +msgstr "" + #. View option #: src/project/browse/EntryListViewOptions.svelte msgid "Simple" @@ -2067,6 +2194,13 @@ msgstr "Item {0} telah dipadam" msgid "This date # and this emoji # are snippets" msgstr "Tarikh ini # dan emoji ini # adalah coretan" +#. Relevant view: Classic +#. Lite view equivalent: "This word may already exist" +#. Warning banner in the New Entry dialog when an entry with the exact same headword exists; expands to a list of possible duplicates +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "This entry may already exist" +msgstr "" + #. Help message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." @@ -2082,6 +2216,13 @@ msgstr "Tugasan ini selesai" msgid "This will synchronize the FieldWorks Lite and FieldWorks Classic copies of your project in Lexbox." msgstr "Ini akan menyegerakkan salinan FieldWorks Lite dan FieldWorks Classic projek anda dalam Lexbox." +#. Relevant view: Lite +#. Classic view equivalent: "This entry may already exist" +#. Warning banner in the New Word dialog when a word with the exact same spelling exists; expands to a list of possible duplicates +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "This word may already exist" +msgstr "" + #. Button to show/hide filter panel #: src/project/browse/SearchFilter.svelte msgid "Toggle filters" @@ -2147,10 +2288,7 @@ msgid "Unable to open in FieldWorks" msgstr "Tidak dapat membuka dalam FieldWorks" #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). -#: src/lib/activity/ActivityFilter.svelte -#: src/lib/activity/ActivityFilter.svelte -#: src/lib/activity/ActivityItem.svelte -#: src/lib/activity/ActivityView.svelte +#: src/lib/activity/AuthorLabel.svelte #: src/lib/activity/utils.ts #: src/project/sync/FwLiteToFwMergeDetails.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte diff --git a/frontend/viewer/src/locales/sw.po b/frontend/viewer/src/locales/sw.po index aacc4c2529..8b8c59754e 100644 --- a/frontend/viewer/src/locales/sw.po +++ b/frontend/viewer/src/locales/sw.po @@ -161,6 +161,13 @@ msgstr "Ongeza Mfano" msgid "Add Meaning" msgstr "Ongeza Maana" +#. Relevant view: Lite +#. Classic view equivalent: "Add sense to this entry" +#. Button on a possible-duplicate result in the New Word dialog: saves the meaning being typed onto that existing word (instead of creating a duplicate) and opens it +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add meaning to this word" +msgstr "" + #. Button in toolbar #: src/lib/entry-editor/NewEntryButton.svelte msgid "Add new word" @@ -181,6 +188,13 @@ msgstr "Ongeza sehemu ya" msgid "Add Sense" msgstr "Ongeza Maana" +#. Relevant view: Classic +#. Lite view equivalent: "Add meaning to this word" +#. Button on a possible-duplicate result in the New Entry dialog: saves the sense being typed onto that existing entry (instead of creating a duplicate) and opens it +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add sense to this entry" +msgstr "" + #. Subtitle of a placeholder row shown at the end of the entry list while searching (also in the no-results state) #. The search text is displayed above it as a headword; clicking opens the new-entry dialog pre-filled with that text #: src/project/browse/EntriesList.svelte @@ -369,6 +383,20 @@ msgstr "Mabadiliko yanaweza pia kuwa matokeo ya sehemu kuongezwa kwa matoleo map msgid "Changing the same field twice, for example, can result in two commits but only one change that needs to be applied to FieldWorks Classic. Commits can also consist of multiple changes." msgstr "Kubadilisha sehemu sawa mara mbili, kwa mfano, kunaweza kusababisha kumbukas mbili lakini badiliko moja tu linayohitaji kutumwa kwa FieldWorks Classic. Kumbukas kunaweza pia kuwa na mabadiliko mengi." +#. Relevant view: Classic +#. Lite view equivalent: "Checking for similar words…" +#. Status line in the New Entry dialog while searching for possible duplicate entries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Checking for similar entries…" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Checking for similar entries…" +#. Status line in the New Word dialog while searching for possible duplicate words +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Checking for similar words…" +msgstr "" + #. Status message while checking for updates #: src/lib/updates/UpdateDialogContent.svelte msgid "Checking for updates..." @@ -905,7 +933,7 @@ msgstr "Toleo la FieldWorks Lite" #. Alt text for FieldWorks logo image #: src/home/HomeView.svelte -#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/AuthorLabel.svelte #: src/lib/components/OpenInFieldWorksButton.svelte #: src/project/ProjectDropdown.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte @@ -1044,6 +1072,20 @@ msgstr "Glosi" msgid "Go to {0}" msgstr "Nenda kwa {0}" +#. Relevant view: Classic +#. Lite view equivalent: "Go to word" +#. Tooltip on a possible-duplicate result in the New Entry dialog; clicking closes the dialog and opens that entry +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Go to entry" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Go to entry" +#. Tooltip on a possible-duplicate result in the New Word dialog; clicking closes the dialog and opens that word +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Go to word" +msgstr "" + #. Filter option label #. Relevant view: Classic #. Lite view equivalent: "Part of speech" @@ -1232,6 +1274,13 @@ msgstr "Ingia kuona miradi" msgid "Logout" msgstr "Toka nje" +#. Relevant view: Lite +#. Classic view equivalent: "No similar entries found" +#. Reassuring status line in the New Word dialog: the duplicate check found no existing words like the one being typed +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Looks like a new word" +msgstr "" + #. Footer text in the project sidebar. The emoji flags are the countries of origin of the development team — do not translate. #: src/project/ProjectSidebar.svelte msgid "Made with ❤️ from 🇦🇹 🇹🇭 🇺🇸" @@ -1277,6 +1326,13 @@ msgstr "Sasisho la mwongozo linahitajika. Tafadhali fuata maelekezo yaliyotolewa msgid "Meaning" msgstr "Maana" +#. Relevant view: Lite +#. Classic view equivalent: "Sense added to \"{0}\"" +#. Success notification after adding the typed meaning to an existing word; {0} is that word's headword +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Meaning added to \"{0}\"" +msgstr "" + #. Task subject label in the task list. {0} = writing system abbreviation (e.g., "en", "fr"). Identifies entries lacking a definition in that writing system. #: src/project/tasks/tasks-service.ts msgid "Missing Definition {0}" @@ -1457,6 +1513,13 @@ msgstr "Hakuna seva" msgid "No server configured" msgstr "Hakuna seva iliyotengwa" +#. Relevant view: Classic +#. Lite view equivalent: "Looks like a new word" +#. Status line in the New Entry dialog: the duplicate check found no existing entries like the one being typed +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "No similar entries found" +msgstr "" + #. Error message in the task editing drawer when no subject entity can be found. {0} = subject type (e.g., "sense", "example-sentence"). #: src/project/tasks/SubjectPopup.svelte msgid "No subject, unable to create a new {0}" @@ -1746,6 +1809,20 @@ msgstr "Tathmini" msgid "rose" msgstr "waridi" +#. Relevant view: Classic +#. Lite view equivalent: "Same word" +#. Badge on a possible-duplicate result: its headword is identical to the one being typed in the New Entry dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Same headword" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Same headword" +#. Badge on a possible-duplicate result: it is spelled exactly like the word being typed in the New Word dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Same word" +msgstr "" + #. Dialog button to save audio with new name #: src/lib/components/field-editors/audio-input.svelte msgid "Save As" @@ -1832,6 +1909,13 @@ msgstr "Tuambii ujumbe" msgid "Sense" msgstr "Kwa maana" +#. Relevant view: Classic +#. Lite view equivalent: "Meaning added to \"{0}\"" +#. Success notification after adding the typed sense to an existing entry; {0} is that entry's headword +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Sense added to \"{0}\"" +msgstr "" + #. Field label for example text #. The sentence/phrase text for an example (source text in original language) #. Paired with "Translation" field for translated version @@ -1870,10 +1954,53 @@ msgid "Show" msgstr "Onyesha" #. Expand button with count +#: src/lib/entry-editor/DuplicateCheck.svelte #: src/lib/entry-editor/EntryOrSensePicker.svelte msgid "Show {0} more..." msgstr "Onyesha {0} zaidi..." +#. Relevant view: Classic +#. Lite view equivalent: "Similar words already exist" +#. Warning banner in the New Entry dialog; expands to a list of possible duplicate entries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar entries already exist" +msgstr "" + +#. Relevant view: Classic +#. Lite view equivalent: "Similar meaning" +#. Badge on a possible-duplicate result: one of its glosses matches the gloss being typed in the New Entry dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar gloss" +msgstr "" + +#. Relevant view: Classic +#. Lite view equivalent: "Similar word" +#. Badge on a possible-duplicate result: its headword partly matches the one being typed in the New Entry dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar headword" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Similar gloss" +#. Badge on a possible-duplicate result: one of its meanings matches the meaning being typed in the New Word dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar meaning" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Similar headword" +#. Badge on a possible-duplicate result: it is spelled almost like the word being typed in the New Word dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar word" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Similar entries already exist" +#. Warning banner in the New Word dialog; expands to a list of possible duplicate words +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar words already exist" +msgstr "" + #. View option #: src/project/browse/EntryListViewOptions.svelte msgid "Simple" @@ -2067,6 +2194,13 @@ msgstr "Hili {0} limefutwa" msgid "This date # and this emoji # are snippets" msgstr "Tarehe hii # na emoji hii # ni vigezo" +#. Relevant view: Classic +#. Lite view equivalent: "This word may already exist" +#. Warning banner in the New Entry dialog when an entry with the exact same headword exists; expands to a list of possible duplicates +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "This entry may already exist" +msgstr "" + #. Help message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." @@ -2082,6 +2216,13 @@ msgstr "Jukumu hili limekamilika" msgid "This will synchronize the FieldWorks Lite and FieldWorks Classic copies of your project in Lexbox." msgstr "Hii itaoanisha kopia za FieldWorks Lite na FieldWorks Classic za mradi wako kwenye Lexbox." +#. Relevant view: Lite +#. Classic view equivalent: "This entry may already exist" +#. Warning banner in the New Word dialog when a word with the exact same spelling exists; expands to a list of possible duplicates +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "This word may already exist" +msgstr "" + #. Button to show/hide filter panel #: src/project/browse/SearchFilter.svelte msgid "Toggle filters" @@ -2147,10 +2288,7 @@ msgid "Unable to open in FieldWorks" msgstr "Haiwezi kufungua katika FieldWorks" #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). -#: src/lib/activity/ActivityFilter.svelte -#: src/lib/activity/ActivityFilter.svelte -#: src/lib/activity/ActivityItem.svelte -#: src/lib/activity/ActivityView.svelte +#: src/lib/activity/AuthorLabel.svelte #: src/lib/activity/utils.ts #: src/project/sync/FwLiteToFwMergeDetails.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte diff --git a/frontend/viewer/src/locales/vi.po b/frontend/viewer/src/locales/vi.po index 08eccb0489..6828556963 100644 --- a/frontend/viewer/src/locales/vi.po +++ b/frontend/viewer/src/locales/vi.po @@ -161,6 +161,13 @@ msgstr "Thêm ví dụ" msgid "Add Meaning" msgstr "Thêm Ý nghĩa" +#. Relevant view: Lite +#. Classic view equivalent: "Add sense to this entry" +#. Button on a possible-duplicate result in the New Word dialog: saves the meaning being typed onto that existing word (instead of creating a duplicate) and opens it +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add meaning to this word" +msgstr "" + #. Button in toolbar #: src/lib/entry-editor/NewEntryButton.svelte msgid "Add new word" @@ -181,6 +188,13 @@ msgstr "Thêm phần của" msgid "Add Sense" msgstr "Thêm Ý nghĩa (sense)" +#. Relevant view: Classic +#. Lite view equivalent: "Add meaning to this word" +#. Button on a possible-duplicate result in the New Entry dialog: saves the sense being typed onto that existing entry (instead of creating a duplicate) and opens it +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add sense to this entry" +msgstr "" + #. Subtitle of a placeholder row shown at the end of the entry list while searching (also in the no-results state) #. The search text is displayed above it as a headword; clicking opens the new-entry dialog pre-filled with that text #: src/project/browse/EntriesList.svelte @@ -369,6 +383,20 @@ msgstr "Các thay đổi cũng có thể do các trường mới được thêm msgid "Changing the same field twice, for example, can result in two commits but only one change that needs to be applied to FieldWorks Classic. Commits can also consist of multiple changes." msgstr "Thay đổi cùng một trường hai lần, ví dụ, có thể dẫn đến hai commit nhưng chỉ một thay đổi cần được áp dụng cho FieldWorks Classic. Commit cũng có thể gồm nhiều thay đổi." +#. Relevant view: Classic +#. Lite view equivalent: "Checking for similar words…" +#. Status line in the New Entry dialog while searching for possible duplicate entries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Checking for similar entries…" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Checking for similar entries…" +#. Status line in the New Word dialog while searching for possible duplicate words +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Checking for similar words…" +msgstr "" + #. Status message while checking for updates #: src/lib/updates/UpdateDialogContent.svelte msgid "Checking for updates..." @@ -905,7 +933,7 @@ msgstr "Phiên bản FieldWorks Lite" #. Alt text for FieldWorks logo image #: src/home/HomeView.svelte -#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/AuthorLabel.svelte #: src/lib/components/OpenInFieldWorksButton.svelte #: src/project/ProjectDropdown.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte @@ -1044,6 +1072,20 @@ msgstr "Ghi chú" msgid "Go to {0}" msgstr "Đi tới {0}" +#. Relevant view: Classic +#. Lite view equivalent: "Go to word" +#. Tooltip on a possible-duplicate result in the New Entry dialog; clicking closes the dialog and opens that entry +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Go to entry" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Go to entry" +#. Tooltip on a possible-duplicate result in the New Word dialog; clicking closes the dialog and opens that word +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Go to word" +msgstr "" + #. Filter option label #. Relevant view: Classic #. Lite view equivalent: "Part of speech" @@ -1232,6 +1274,13 @@ msgstr "Đăng nhập để xem dự án" msgid "Logout" msgstr "Đăng xuất" +#. Relevant view: Lite +#. Classic view equivalent: "No similar entries found" +#. Reassuring status line in the New Word dialog: the duplicate check found no existing words like the one being typed +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Looks like a new word" +msgstr "" + #. Footer text in the project sidebar. The emoji flags are the countries of origin of the development team — do not translate. #: src/project/ProjectSidebar.svelte msgid "Made with ❤️ from 🇦🇹 🇹🇭 🇺🇸" @@ -1277,6 +1326,13 @@ msgstr "Cần thực hiện cập nhật thủ công. Vui lòng làm theo các h msgid "Meaning" msgstr "Ý nghĩa" +#. Relevant view: Lite +#. Classic view equivalent: "Sense added to \"{0}\"" +#. Success notification after adding the typed meaning to an existing word; {0} is that word's headword +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Meaning added to \"{0}\"" +msgstr "" + #. Task subject label in the task list. {0} = writing system abbreviation (e.g., "en", "fr"). Identifies entries lacking a definition in that writing system. #: src/project/tasks/tasks-service.ts msgid "Missing Definition {0}" @@ -1457,6 +1513,13 @@ msgstr "Không có máy chủ" msgid "No server configured" msgstr "Chưa cấu hình máy chủ" +#. Relevant view: Classic +#. Lite view equivalent: "Looks like a new word" +#. Status line in the New Entry dialog: the duplicate check found no existing entries like the one being typed +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "No similar entries found" +msgstr "" + #. Error message in the task editing drawer when no subject entity can be found. {0} = subject type (e.g., "sense", "example-sentence"). #: src/project/tasks/SubjectPopup.svelte msgid "No subject, unable to create a new {0}" @@ -1746,6 +1809,20 @@ msgstr "Xem lại" msgid "rose" msgstr "hoa hồng" +#. Relevant view: Classic +#. Lite view equivalent: "Same word" +#. Badge on a possible-duplicate result: its headword is identical to the one being typed in the New Entry dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Same headword" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Same headword" +#. Badge on a possible-duplicate result: it is spelled exactly like the word being typed in the New Word dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Same word" +msgstr "" + #. Dialog button to save audio with new name #: src/lib/components/field-editors/audio-input.svelte msgid "Save As" @@ -1832,6 +1909,13 @@ msgstr "Gửi cho chúng tôi một tin nhắn" msgid "Sense" msgstr "Ý nghĩa (sense)" +#. Relevant view: Classic +#. Lite view equivalent: "Meaning added to \"{0}\"" +#. Success notification after adding the typed sense to an existing entry; {0} is that entry's headword +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Sense added to \"{0}\"" +msgstr "" + #. Field label for example text #. The sentence/phrase text for an example (source text in original language) #. Paired with "Translation" field for translated version @@ -1870,10 +1954,53 @@ msgid "Show" msgstr "Hiển thị" #. Expand button with count +#: src/lib/entry-editor/DuplicateCheck.svelte #: src/lib/entry-editor/EntryOrSensePicker.svelte msgid "Show {0} more..." msgstr "Hiển thị thêm {0}..." +#. Relevant view: Classic +#. Lite view equivalent: "Similar words already exist" +#. Warning banner in the New Entry dialog; expands to a list of possible duplicate entries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar entries already exist" +msgstr "" + +#. Relevant view: Classic +#. Lite view equivalent: "Similar meaning" +#. Badge on a possible-duplicate result: one of its glosses matches the gloss being typed in the New Entry dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar gloss" +msgstr "" + +#. Relevant view: Classic +#. Lite view equivalent: "Similar word" +#. Badge on a possible-duplicate result: its headword partly matches the one being typed in the New Entry dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar headword" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Similar gloss" +#. Badge on a possible-duplicate result: one of its meanings matches the meaning being typed in the New Word dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar meaning" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Similar headword" +#. Badge on a possible-duplicate result: it is spelled almost like the word being typed in the New Word dialog +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar word" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Similar entries already exist" +#. Warning banner in the New Word dialog; expands to a list of possible duplicate words +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Similar words already exist" +msgstr "" + #. View option #: src/project/browse/EntryListViewOptions.svelte msgid "Simple" @@ -2067,6 +2194,13 @@ msgstr "Đường dẫn này {0} đã bị xóa." msgid "This date # and this emoji # are snippets" msgstr "Ngày này # và biểu tượng cảm xúc này # là đoạn trích" +#. Relevant view: Classic +#. Lite view equivalent: "This word may already exist" +#. Warning banner in the New Entry dialog when an entry with the exact same headword exists; expands to a list of possible duplicates +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "This entry may already exist" +msgstr "" + #. Help message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." @@ -2082,6 +2216,13 @@ msgstr "Nhiệm vụ này đã hoàn thành" msgid "This will synchronize the FieldWorks Lite and FieldWorks Classic copies of your project in Lexbox." msgstr "Điều này sẽ đồng bộ các bản sao FieldWorks Lite và FieldWorks Classic của dự án của bạn trong Lexbox." +#. Relevant view: Lite +#. Classic view equivalent: "This entry may already exist" +#. Warning banner in the New Word dialog when a word with the exact same spelling exists; expands to a list of possible duplicates +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "This word may already exist" +msgstr "" + #. Button to show/hide filter panel #: src/project/browse/SearchFilter.svelte msgid "Toggle filters" @@ -2147,10 +2288,7 @@ msgid "Unable to open in FieldWorks" msgstr "Không thể mở trong FieldWorks" #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). -#: src/lib/activity/ActivityFilter.svelte -#: src/lib/activity/ActivityFilter.svelte -#: src/lib/activity/ActivityItem.svelte -#: src/lib/activity/ActivityView.svelte +#: src/lib/activity/AuthorLabel.svelte #: src/lib/activity/utils.ts #: src/project/sync/FwLiteToFwMergeDetails.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte diff --git a/frontend/viewer/tests/new-entry-duplicates.test.ts b/frontend/viewer/tests/new-entry-duplicates.test.ts new file mode 100644 index 0000000000..c40e9f82ce --- /dev/null +++ b/frontend/viewer/tests/new-entry-duplicates.test.ts @@ -0,0 +1,135 @@ +import {expect, test, type Locator, type Page} from '@playwright/test'; +import {BrowsePage} from './browse-page'; + +/** + * Tests for the possible-duplicates check in the new entry dialog: + * typing an existing word/gloss surfaces similar entries, a brand-new word + * gets the "no similar entries" indicator, and a duplicate can be opened. + */ + +// demo data (see demo-entry-data.ts): entry 'baba' exists, glossed 'father' +const existingLexeme = 'baba'; +const existingGloss = 'father'; + +async function openNewEntryDialog(page: Page): Promise { + await page.getByRole('button', {name: /new (entry|word)/i}).first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + return dialog; +} + +function lexemeInput(dialog: Locator): Locator { + return dialog.locator('[style*="grid-area: lexemeForm"]').locator('input').first(); +} + +function glossInput(dialog: Locator): Locator { + return dialog.locator('[style*="grid-area: gloss"]').locator('input').first(); +} + +const duplicatesSummary = /already exist/i; +const newWordIndicator = /no similar entries found|looks like a new word/i; + +function duplicateRows(dialog: Locator): Locator { + return dialog.getByRole('button', {name: /^go to (entry|word)/i}); +} + +test.describe('New entry possible duplicates', () => { + test('typing an existing word shows duplicates and can navigate to one', async ({page}) => { + const browsePage = new BrowsePage(page); + await browsePage.goto(); + + const dialog = await openNewEntryDialog(page); + await lexemeInput(dialog).fill(existingLexeme); + + // exact headword match => attention strip + auto-expanded list + await expect(dialog.getByText(duplicatesSummary)).toBeVisible(); + // 'baba' is a substring of 'ubaba', so both rows match — .first() is the exact match because same-word sorts first + const duplicateRow = duplicateRows(dialog).filter({hasText: existingLexeme}).first(); + await expect(duplicateRow).toBeVisible(); + + await duplicateRow.click(); + await expect(dialog).toBeHidden(); + await expect(page).toHaveURL(/entryId=/); + const openedLexeme = await browsePage.entryView.getLexemeInput(); + await expect(openedLexeme).toHaveValue(existingLexeme); + }); + + test('brand-new word shows the new-word indicator', async ({page}) => { + const browsePage = new BrowsePage(page); + await browsePage.goto(); + + const dialog = await openNewEntryDialog(page); + await lexemeInput(dialog).fill('zyzzyvazz'); + await expect(dialog.getByText(newWordIndicator)).toBeVisible(); + await expect(dialog.getByText(duplicatesSummary)).toBeHidden(); + }); + + test('typed meaning can be added to an existing entry instead', async ({page}) => { + const browsePage = new BrowsePage(page); + await browsePage.goto(); + + const dialog = await openNewEntryDialog(page); + await lexemeInput(dialog).fill(existingLexeme); + const newGloss = `rescued-${Date.now().toString().slice(-6)}`; + await glossInput(dialog).fill(newGloss); + + // exact match auto-expands the list + const row = dialog.locator('li').filter({hasText: existingLexeme}).first(); + await row.getByRole('button', {name: /add (sense|meaning)/i}).click(); + + await expect(dialog).toBeHidden(); + await expect(page).toHaveURL(/entryId=/); + const entryId = new URL(page.url()).searchParams.get('entryId')!; + await expect(async () => { + expect(await browsePage.api.entryHasGlossValue(entryId, newGloss)).toBe(true); + }).toPass({timeout: 5000}); + }); + + test('partial headword match shows a collapsed strip with a similar-word badge', async ({page}) => { + const browsePage = new BrowsePage(page); + await browsePage.goto(); + + const dialog = await openNewEntryDialog(page); + // substring of 'balalika' only — no exact match, so the strip stays collapsed + await lexemeInput(dialog).fill('balal'); + + const summary = dialog.getByText(duplicatesSummary); + await expect(summary).toBeVisible(); + await expect(duplicateRows(dialog)).toHaveCount(0); + await summary.click(); + await expect(dialog.getByText(/similar (headword|word)/i).first()).toBeVisible(); + }); + + test('long match lists collapse behind Show more and a capped count', async ({page}) => { + const browsePage = new BrowsePage(page); + await browsePage.goto(); + + const dialog = await openNewEntryDialog(page); + await lexemeInput(dialog).fill('ba'); + + const summary = dialog.getByText(duplicatesSummary); + await expect(summary).toBeVisible(); + await expect(dialog.getByText('10+')).toBeVisible(); + + const rows = duplicateRows(dialog); + if (await rows.count() === 0) await summary.click(); // expands automatically only on an exact match + await expect(rows).toHaveCount(3); + await dialog.getByRole('button', {name: /show \d+ more/i}).click(); + expect(await rows.count()).toBeGreaterThan(3); + }); + + test('matching gloss shows duplicates with a meaning badge', async ({page}) => { + const browsePage = new BrowsePage(page); + await browsePage.goto(); + + const dialog = await openNewEntryDialog(page); + await lexemeInput(dialog).fill('zyzzyvazz'); + await glossInput(dialog).fill(existingGloss); + + const summary = dialog.getByText(duplicatesSummary); + await expect(summary).toBeVisible(); + // gloss-only matches don't auto-expand; expand to see the badge + await summary.click(); + await expect(dialog.getByText(/similar (gloss|meaning)/i).first()).toBeVisible(); + }); +}); From ad8bfa5454442f50a83fc94fa4996162ba140f06 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 6 Jul 2026 09:08:43 +0200 Subject: [PATCH 2/4] Guard against concurrent duplicate-row actions (CodeRabbit review) Co-Authored-By: Claude Fable 5 --- .../src/lib/entry-editor/DuplicateCheck.svelte | 17 ++++++++++------- .../src/lib/entry-editor/NewEntryDialog.svelte | 6 ++++-- .../viewer/tests/new-entry-duplicates.test.ts | 6 ++++-- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/frontend/viewer/src/lib/entry-editor/DuplicateCheck.svelte b/frontend/viewer/src/lib/entry-editor/DuplicateCheck.svelte index aa9f4238fe..84041f7782 100644 --- a/frontend/viewer/src/lib/entry-editor/DuplicateCheck.svelte +++ b/frontend/viewer/src/lib/entry-editor/DuplicateCheck.svelte @@ -26,9 +26,11 @@ sense?: ISense; /** Called right before navigating to an existing entry, so the host dialog can close itself. */ onNavigateToEntry?: (entry: IEntry) => void; + /** True while an add-sense save is in flight — the host dialog should block submitting until it settles. */ + busy?: boolean; } - const {entry, sense, onNavigateToEntry}: Props = $props(); + let {entry, sense, onNavigateToEntry, busy = $bindable(false)}: Props = $props(); const lexboxApi = useLexboxApi(); const writingSystemService = useWritingSystemService(); @@ -108,16 +110,16 @@ // Rescues the meaning the user already typed: instead of creating a duplicate entry, // it becomes a new sense of the existing one. const canAddSense = $derived(!!sense && !!writingSystemService.firstDefOrGlossVal(sense)); - let addingSense = $state(false); async function addSenseToEntry(target: IEntry): Promise { - if (!sense || addingSense) return; - addingSense = true; + if (!sense || busy) return; + busy = true; try { - const senseSnapshot = {...$state.snapshot(sense), entryId: target.id}; + // fresh id: the dialog's sense id must never end up on two entries (e.g. add-sense then create) + const senseSnapshot = {...$state.snapshot(sense), id: crypto.randomUUID(), entryId: target.id}; await saveHandler.handleSave(() => lexboxApi.createSense(target.id, senseSnapshot)); } finally { - addingSense = false; + busy = false; } AppNotification.display( pt($t`Sense added to "${writingSystemService.headword(target)}"`, @@ -179,6 +181,7 @@ class="grow min-w-0 flex items-center gap-2 rounded bg-background/80 hover:bg-accent px-2.5 py-2 text-start" title={goToLabel} aria-label={headword ? `${goToLabel}: ${headword}` : goToLabel} + disabled={busy} onkeydown={trapEnter} onclick={() => openEntry(match.entry)}>
@@ -200,7 +203,7 @@ class="self-center shrink-0" title={addSenseLabel} aria-label={addSenseLabel} - disabled={addingSense} + disabled={busy} onkeydown={trapEnter} onclick={() => addSenseToEntry(match.entry)} /> {/if} diff --git a/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte b/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte index a88970132f..bf3374187b 100644 --- a/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte +++ b/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte @@ -32,6 +32,7 @@ let open = $state(false); useBackHandler({addToStack: () => open, onBack: () => open = false, key: 'new-entry-dialog'}); let loading = $state(false); + let duplicateActionBusy = $state(false); let entry = $state(defaultEntry()); let sense = $state(untrack(() => defaultSense(entry.id))); @@ -60,6 +61,7 @@ async function createEntry(e: Event) { e.preventDefault(); e.stopPropagation(); + if (duplicateActionBusy) return; // a pending add-sense already consumes the typed meaning if (!requester) throw new Error('No requester'); await editor?.commit(); @@ -213,7 +215,7 @@
- open = false} /> + open = false} />
{#if errors.length} @@ -225,7 +227,7 @@ {/if} - diff --git a/frontend/viewer/tests/new-entry-duplicates.test.ts b/frontend/viewer/tests/new-entry-duplicates.test.ts index c40e9f82ce..6323af6fbe 100644 --- a/frontend/viewer/tests/new-entry-duplicates.test.ts +++ b/frontend/viewer/tests/new-entry-duplicates.test.ts @@ -79,9 +79,10 @@ test.describe('New entry possible duplicates', () => { await expect(dialog).toBeHidden(); await expect(page).toHaveURL(/entryId=/); - const entryId = new URL(page.url()).searchParams.get('entryId')!; + const entryId = new URL(page.url()).searchParams.get('entryId'); + expect(entryId).toBeTruthy(); await expect(async () => { - expect(await browsePage.api.entryHasGlossValue(entryId, newGloss)).toBe(true); + expect(await browsePage.api.entryHasGlossValue(entryId!, newGloss)).toBe(true); }).toPass({timeout: 5000}); }); @@ -113,6 +114,7 @@ test.describe('New entry possible duplicates', () => { const rows = duplicateRows(dialog); if (await rows.count() === 0) await summary.click(); // expands automatically only on an exact match + // 3 = the component's initial display count; '10+' assumes the demo data has >10 entries containing 'ba' await expect(rows).toHaveCount(3); await dialog.getByRole('button', {name: /show \d+ more/i}).click(); expect(await rows.count()).toBeGreaterThan(3); From 96f287f3df5e058cf8df3cfe0023ff4a55d43ac3 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 6 Jul 2026 09:55:13 +0200 Subject: [PATCH 3/4] Align duplicate classification with backend match semantics Spec-cited review against EntrySearchService/SqlHelpers found the client fold diverged: host-locale lowercasing, unconditional accent-stripping (backend keeps diacritics significant when the query has them), and no morph-token handling (typed "-aji" vs suffix entry "aji" missed exact). Also ranks each search by the writing system it was typed in, fixes the mount-time "Checking" flash, singularizes the one-match banner, and closes a pre-existing double-Enter double-create in the dialog. Co-Authored-By: Claude Fable 5 --- .../lib/entry-editor/DuplicateCheck.svelte | 21 ++- .../lib/entry-editor/NewEntryDialog.svelte | 31 ++-- .../lib/entry-editor/duplicate-check.test.ts | 95 +++++++++++- .../src/lib/entry-editor/duplicate-check.ts | 145 +++++++++++++----- frontend/viewer/src/locales/en.po | 14 ++ frontend/viewer/src/locales/es.po | 14 ++ frontend/viewer/src/locales/fr.po | 14 ++ frontend/viewer/src/locales/id.po | 14 ++ frontend/viewer/src/locales/ko.po | 14 ++ frontend/viewer/src/locales/ms.po | 14 ++ frontend/viewer/src/locales/sw.po | 14 ++ frontend/viewer/src/locales/vi.po | 14 ++ 12 files changed, 341 insertions(+), 63 deletions(-) diff --git a/frontend/viewer/src/lib/entry-editor/DuplicateCheck.svelte b/frontend/viewer/src/lib/entry-editor/DuplicateCheck.svelte index 84041f7782..2892cfd90a 100644 --- a/frontend/viewer/src/lib/entry-editor/DuplicateCheck.svelte +++ b/frontend/viewer/src/lib/entry-editor/DuplicateCheck.svelte @@ -14,6 +14,7 @@ import {AppNotification} from '$lib/notifications/notifications'; import {useSaveHandler} from '$lib/services/save-event-service.svelte'; import {useLexboxApi} from '$lib/services/service-provider'; + import {useMorphTypesService} from '$project/data/morph-types.svelte'; import {useWritingSystemService} from '$project/data'; import {useViewService} from '$lib/views/view-service.svelte'; import {pt} from '$lib/views/view-text'; @@ -34,6 +35,7 @@ const lexboxApi = useLexboxApi(); const writingSystemService = useWritingSystemService(); + const morphTypesService = useMorphTypesService(); const viewService = useViewService(); const saveHandler = useSaveHandler(); const {base} = useRouter(); @@ -44,24 +46,29 @@ const vernacularWsIds = $derived(writingSystemService.vernacularNoAudio.map(ws => ws.wsId)); const analysisWsIds = $derived(writingSystemService.analysisNoAudio.map(ws => ws.wsId)); const queries = $derived(duplicateQueries(entry, sense, vernacularWsIds, analysisWsIds)); + const hasQueries = $derived(queries.vernacular.length + queries.analysis.length > 0); const duplicatesResource = resource( // string key, so edits to unrelated fields don't retrigger the search () => JSON.stringify([queries.vernacular, queries.analysis]), async (_key, _prev, {signal}): Promise<{matches: DuplicateMatch[], capped: boolean} | undefined> => { - const allQueries = [...queries.vernacular, ...queries.analysis]; - if (!allQueries.length) return undefined; - const results = await Promise.all(allQueries.map(query => lexboxApi.searchEntries(query, { + // rank each search by the writing system the text was typed in, so that WS's headword matches sort first + const searches = [ + ...queries.vernacular.map(query => ({text: query.text, writingSystem: query.wsId})), + ...queries.analysis.map(text => ({text, writingSystem: 'default'})), + ]; + if (!searches.length) return undefined; + const results = await Promise.all(searches.map(search => lexboxApi.searchEntries(search.text, { offset: 0, count: FETCH_COUNT, - order: {field: SortField.SearchRelevance, writingSystem: 'default', ascending: true}, + order: {field: SortField.SearchRelevance, writingSystem: search.writingSystem, ascending: true}, }))); // searchEntries can't take the abort signal over JSInterop and `resource` keeps whatever // resolves last, so discard results that a newer keystroke has already superseded if (signal.aborted) throw new DOMException('superseded duplicate search', 'AbortError'); const candidates = mergeSearchResults(results); return { - matches: classifyDuplicates(candidates, queries, vernacularWsIds, analysisWsIds), + matches: classifyDuplicates(candidates, queries, vernacularWsIds, analysisWsIds, morphTypesService.current), capped: results.some(result => result.length >= FETCH_COUNT), }; }, @@ -137,7 +144,7 @@
{#if !matches?.length} - {#if duplicatesResource.loading || matches} + {#if (duplicatesResource.loading && hasQueries) || matches}
{#if duplicatesResource.loading} @@ -159,6 +166,8 @@ {#if hasExactWordMatch} {pt($t`This entry may already exist`, $t`This word may already exist`, viewService.currentView)} + {:else if matches.length === 1} + {pt($t`A similar entry already exists`, $t`A similar word already exists`, viewService.currentView)} {:else} {pt($t`Similar entries already exist`, $t`Similar words already exist`, viewService.currentView)} {/if} diff --git a/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte b/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte index bf3374187b..e5e84d9b35 100644 --- a/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte +++ b/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte @@ -61,22 +61,27 @@ async function createEntry(e: Event) { e.preventDefault(); e.stopPropagation(); - if (duplicateActionBusy) return; // a pending add-sense already consumes the typed meaning + // loading: double-Enter must not create twice, so it flips before the first await + // duplicateActionBusy: a pending add-sense already consumes the typed meaning + if (loading || duplicateActionBusy) return; if (!requester) throw new Error('No requester'); - await editor?.commit(); - await addMainPublicationPromise; // make sure the main publication landed before we snapshot the entry - entry.senses = sense ? [sense] : []; - if (!validateEntry()) return; - loading = true; - const entrySnapshot = $state.snapshot(entry); - // The dialog pre-populates publishIn (main publication + any active filter), so always create the entry as-is. - await saveHandler.handleSave(() => lexboxApi.createEntry(entrySnapshot, createEntryOptions.asIs)); - requester.resolve(entry); - requester = undefined; - loading = false; - open = false; + try { + await editor?.commit(); + await addMainPublicationPromise; // make sure the main publication landed before we snapshot the entry + entry.senses = sense ? [sense] : []; + if (!validateEntry()) return; + + const entrySnapshot = $state.snapshot(entry); + // The dialog pre-populates publishIn (main publication + any active filter), so always create the entry as-is. + await saveHandler.handleSave(() => lexboxApi.createEntry(entrySnapshot, createEntryOptions.asIs)); + requester.resolve(entry); + requester = undefined; + open = false; + } finally { + loading = false; + } } let errors: string[] = $state([]); diff --git a/frontend/viewer/src/lib/entry-editor/duplicate-check.test.ts b/frontend/viewer/src/lib/entry-editor/duplicate-check.test.ts index e0d1e71701..5836fdc74d 100644 --- a/frontend/viewer/src/lib/entry-editor/duplicate-check.test.ts +++ b/frontend/viewer/src/lib/entry-editor/duplicate-check.test.ts @@ -1,9 +1,19 @@ import {describe, expect, it} from 'vitest'; import type {IEntry} from '$lib/dotnet-types'; -import {classifyDuplicates, duplicateQueries, mergeSearchResults, normalizeForCompare} from './duplicate-check'; +import { + classifyDuplicates, + duplicateQueries, + hasDiacritics, + mergeSearchResults, + normalizeForCompare, + stripMorphTokens, + type DuplicateQueries, +} from './duplicate-check'; const vernWs = ['seh', 'por']; const analysisWs = ['en', 'fr']; +// canonical suffix/prefix morph-token shapes (CanonicalMorphTypes) +const morphTypes = [{prefix: '-', postfix: undefined}, {prefix: undefined, postfix: '-'}]; function makeEntry(partial: Partial): IEntry { return { @@ -22,22 +32,63 @@ function withGloss(lexeme: string, gloss: string): IEntry { }); } +function vernQueries(...texts: string[]): DuplicateQueries { + return {vernacular: texts.map(text => ({text, wsId: 'seh'})), analysis: []}; +} + describe('normalizeForCompare', () => { - it('ignores case and accents', () => { + it('ignores case and accents by default', () => { expect(normalizeForCompare('Ñumbá ')).toBe('numba'); expect(normalizeForCompare('CAFÉ')).toBe('cafe'); }); + + it('keeps diacritics when asked, still folding case', () => { + expect(normalizeForCompare('CAFÉ', true)).toBe('café'.normalize('NFD')); + expect(normalizeForCompare('CAFÉ', true)).not.toBe('cafe'); + }); + + it('folds case invariantly, not by host locale', () => { + // must never produce Turkish dotless ı for ASCII I + expect(normalizeForCompare('IZGARA')).toBe('izgara'); + }); +}); + +describe('hasDiacritics', () => { + it('detects combining marks in composed and decomposed input', () => { + expect(hasDiacritics('café')).toBe(true); + expect(hasDiacritics('café')).toBe(true); + expect(hasDiacritics('cafe')).toBe(false); + }); +}); + +describe('stripMorphTokens', () => { + it('strips a leading token', () => { + expect(stripMorphTokens('-aji', morphTypes)).toBe('aji'); + }); + + it('strips a trailing token', () => { + expect(stripMorphTokens('a-', morphTypes)).toBe('a'); + }); + + it('prefers the type matching a leading token over a trailing one', () => { + // '-a-' matches both shapes; leading-token match scores higher (mirrors backend scoring) + expect(stripMorphTokens('-a-', [{prefix: '-', postfix: '-'}])).toBe('a'); + }); + + it('leaves untokenized input alone', () => { + expect(stripMorphTokens('aji', morphTypes)).toBe('aji'); + }); }); describe('duplicateQueries', () => { - it('collects distinct vernacular and gloss texts', () => { + it('collects distinct vernacular texts with their writing system, and gloss texts', () => { const queries = duplicateQueries( {lexemeForm: {seh: 'nyumba', por: 'casa'}, citationForm: {seh: 'nyumba'}}, {gloss: {en: 'house', fr: ''}}, vernWs, analysisWs, ); - expect(queries.vernacular).toEqual(['nyumba', 'casa']); + expect(queries.vernacular).toEqual([{text: 'nyumba', wsId: 'seh'}, {text: 'casa', wsId: 'por'}]); expect(queries.analysis).toEqual(['house']); }); @@ -52,10 +103,20 @@ describe('duplicateQueries', () => { expect(queries.analysis).toEqual([]); }); + it('accepts a value exactly at the length threshold', () => { + const queries = duplicateQueries( + {lexemeForm: {seh: 'ba'}, citationForm: {}}, + undefined, + vernWs, + analysisWs, + ); + expect(queries.vernacular).toEqual([{text: 'ba', wsId: 'seh'}]); + }); + it('measures the length threshold on the normalized text', () => { // 'e' + combining acute is 2 chars raw but 1 char once marks are stripped const queries = duplicateQueries( - {lexemeForm: {seh: 'é'}, citationForm: {}}, + {lexemeForm: {seh: 'é'}, citationForm: {}}, undefined, vernWs, analysisWs, @@ -70,7 +131,7 @@ describe('duplicateQueries', () => { vernWs, analysisWs, ); - expect(queries.vernacular).toEqual(['café']); + expect(queries.vernacular).toEqual([{text: 'café', wsId: 'seh'}]); }); it('ignores values in writing systems outside the given lists', () => { @@ -80,7 +141,7 @@ describe('duplicateQueries', () => { vernWs, analysisWs, ); - expect(queries.vernacular).toEqual(['nyumba']); + expect(queries.vernacular).toEqual([{text: 'nyumba', wsId: 'seh'}]); }); }); @@ -94,7 +155,7 @@ describe('mergeSearchResults', () => { }); describe('classifyDuplicates', () => { - const queries = {vernacular: ['nyumba'], analysis: ['house']}; + const queries: DuplicateQueries = {vernacular: [{text: 'nyumba', wsId: 'seh'}], analysis: ['house']}; it('classifies exact headword matches as same-word, even via citation form or other ws', () => { const byLexeme = makeEntry({lexemeForm: {seh: 'Nyumbá'}}); @@ -110,6 +171,24 @@ describe('classifyDuplicates', () => { expect(result.map(m => m.kind)).toEqual(['similar-word', 'similar-word']); }); + it('treats a typed morph token like the backend: "-aji" is the same word as suffix entry "aji"', () => { + const suffixEntry = makeEntry({lexemeForm: {seh: 'aji'}}); + const result = classifyDuplicates([suffixEntry], vernQueries('-aji'), vernWs, analysisWs, morphTypes); + expect(result[0].kind).toBe('same-word'); + }); + + it('is accent-insensitive only when the typed text has no diacritics (backend parity)', () => { + const plain = makeEntry({lexemeForm: {seh: 'cafe'}}); + const accented = makeEntry({lexemeForm: {seh: 'café'}}); + // typed without diacritics -> accents ignored -> both are the same word + expect(classifyDuplicates([plain, accented], vernQueries('cafe'), vernWs, analysisWs).map(m => m.kind)) + .toEqual(['same-word', 'same-word']); + // typed with diacritics -> accents significant -> only the accented entry matches exactly + const result = classifyDuplicates([accented, plain], vernQueries('café'), vernWs, analysisWs); + expect(result[0]).toEqual({entry: accented, kind: 'same-word'}); + expect(result[1].kind).not.toBe('same-word'); + }); + it('classifies gloss overlap as same-meaning', () => { const entry = withGloss('cabana', 'house'); expect(classifyDuplicates([entry], queries, vernWs, analysisWs)[0].kind).toBe('same-meaning'); diff --git a/frontend/viewer/src/lib/entry-editor/duplicate-check.ts b/frontend/viewer/src/lib/entry-editor/duplicate-check.ts index fa6ca20019..cd29704fe4 100644 --- a/frontend/viewer/src/lib/entry-editor/duplicate-check.ts +++ b/frontend/viewer/src/lib/entry-editor/duplicate-check.ts @@ -1,10 +1,11 @@ -import type {IEntry, ISense} from '$lib/dotnet-types'; +import type {IEntry, IMorphType, ISense} from '$lib/dotnet-types'; export const MIN_QUERY_LENGTH = 2; /** - * Ordered strongest to weakest; `related` covers candidates the backend matched some other - * way (e.g. via definition text), which we still show but without a match badge. + * Ordered strongest to weakest; `related` covers candidates the backend matched in ways we + * don't classify: cross-field hits (vernacular text matching a gloss or vice versa), matches + * in writing systems outside the given lists, or collation equivalences we don't replicate. */ export type DuplicateMatchKind = 'same-word' | 'similar-word' | 'same-meaning' | 'related'; @@ -13,13 +14,21 @@ export interface DuplicateMatch { kind: DuplicateMatchKind; } +export interface VernacularQuery { + text: string; + /** The writing system the text was typed in — used to rank that WS's headword matches first */ + wsId: string; +} + export interface DuplicateQueries { /** Texts the user typed into vernacular fields (lexeme form, citation form) */ - vernacular: string[]; + vernacular: VernacularQuery[]; /** Texts the user typed into gloss fields */ analysis: string[]; } +export type MorphTokenSource = Pick; + const kindRank: Record = { 'same-word': 0, 'similar-word': 1, @@ -27,23 +36,43 @@ const kindRank: Record = { 'related': 3, }; -// mirrors the backend's ContainsIgnoreCaseAccents comparisons (SqlHelpers) -export function normalizeForCompare(value: string): string { - return value.normalize('NFD').replace(/\p{Mn}/gu, '').toLocaleLowerCase().trim(); +/** + * Mirrors the backend fold (SqlHelpers.ContainsIgnoreCaseAccents → StringExtensions): + * invariant case fold, and diacritics are significant only when the search text itself + * contains them — hence the keepDiacritics mode, chosen per query via hasDiacritics(). + * Collation-level equivalences (ß≈ss, ligatures) are not replicated. + */ +export function normalizeForCompare(value: string, keepDiacritics = false): string { + const decomposed = value.normalize('NFD'); + const folded = keepDiacritics ? decomposed : decomposed.replace(/\p{Mn}/gu, ''); + return folded.toLowerCase().trim(); } -function distinctQueries(values: (string | undefined)[]): string[] { - const seen = new Set(); - const queries: string[] = []; - for (const value of values) { - const trimmed = value?.trim(); - if (!trimmed) continue; - const normalized = normalizeForCompare(trimmed); - if (normalized.length < MIN_QUERY_LENGTH || seen.has(normalized)) continue; - seen.add(normalized); - queries.push(trimmed); +export function hasDiacritics(value: string): boolean { + return /\p{Mn}/u.test(value.normalize('NFD')); +} + +/** Mirrors the backend's EntrySearchService.StripMorphTokens best-match strip. */ +export function stripMorphTokens(value: string, morphTypes: readonly MorphTokenSource[]): string { + let bestScore = 0; + let bestMatch: MorphTokenSource | undefined; + for (const morphType of morphTypes) { + const prefix = morphType.prefix?.trim() ? morphType.prefix : undefined; + const postfix = morphType.postfix?.trim() ? morphType.postfix : undefined; + const matchesPrefix = !!prefix && value.startsWith(prefix); + const matchesPostfix = !!postfix && value.endsWith(postfix) + && (!matchesPrefix || value.length >= prefix.length + postfix.length); + const score = (matchesPrefix ? 2 : 0) + (matchesPostfix ? 1 : 0); + if (score > bestScore) { + bestScore = score; + bestMatch = morphType; + } } - return queries; + if (!bestMatch) return value; + let result = value; + if (bestMatch.prefix?.trim() && result.startsWith(bestMatch.prefix)) result = result.slice(bestMatch.prefix.length); + if (bestMatch.postfix?.trim() && result.endsWith(bestMatch.postfix)) result = result.slice(0, -bestMatch.postfix.length); + return result; } export function duplicateQueries( @@ -52,10 +81,29 @@ export function duplicateQueries( vernacularWsIds: string[], analysisWsIds: string[], ): DuplicateQueries { - return { - vernacular: distinctQueries(vernacularWsIds.flatMap(ws => [entry.lexemeForm?.[ws], entry.citationForm?.[ws]])), - analysis: distinctQueries(analysisWsIds.map(ws => sense?.gloss?.[ws])), - }; + const seen = new Set(); + const vernacular: VernacularQuery[] = []; + for (const wsId of vernacularWsIds) { + for (const value of [entry.lexemeForm?.[wsId], entry.citationForm?.[wsId]]) { + const trimmed = value?.trim(); + if (!trimmed) continue; + const normalized = normalizeForCompare(trimmed); + if (normalized.length < MIN_QUERY_LENGTH || seen.has(normalized)) continue; + seen.add(normalized); + vernacular.push({text: trimmed, wsId}); + } + } + + const analysis: string[] = []; + for (const value of analysisWsIds.map(wsId => sense?.gloss?.[wsId])) { + const trimmed = value?.trim(); + if (!trimmed) continue; + const normalized = normalizeForCompare(trimmed); + if (normalized.length < MIN_QUERY_LENGTH || seen.has(normalized)) continue; + seen.add(normalized); + analysis.push(trimmed); + } + return {vernacular, analysis}; } /** Merges per-query search results into a single relevance-ordered candidate list. */ @@ -72,31 +120,56 @@ export function mergeSearchResults(results: IEntry[][]): IEntry[] { * Classifies search results against what the user typed and orders them strongest match first * (headword matches before gloss matches). `candidates` are expected in search-relevance order, * which is preserved within each kind. + * + * Same-word is deliberately broader than the backend's headwordMatches ranking: any lexeme or + * citation form in any given vernacular WS counts, and typed morph tokens (e.g. "-a" on a + * suffix) are stripped the way the backend strips them before matching lexeme forms. */ export function classifyDuplicates( candidates: IEntry[], queries: DuplicateQueries, vernacularWsIds: string[], analysisWsIds: string[], + morphTypes: readonly MorphTokenSource[] = [], ): DuplicateMatch[] { - const vernQueries = queries.vernacular.map(normalizeForCompare); - const analysisQueries = queries.analysis.map(normalizeForCompare); + const vernQueries = queries.vernacular.map(({text}) => { + const keepDiacritics = hasDiacritics(text); + return { + keepDiacritics, + variants: [...new Set([ + normalizeForCompare(text, keepDiacritics), + normalizeForCompare(stripMorphTokens(text, morphTypes), keepDiacritics), + ])], + }; + }); + const analysisQueries = queries.analysis.map(text => ({ + keepDiacritics: hasDiacritics(text), + text, + })); function classify(entry: IEntry): DuplicateMatchKind { - const forms = vernacularWsIds.flatMap(ws => [entry.lexemeForm?.[ws], entry.citationForm?.[ws]]) - .filter((form): form is string => !!form) - .map(normalizeForCompare); - if (vernQueries.length) { - if (forms.some(form => vernQueries.includes(form))) return 'same-word'; - if (forms.some(form => vernQueries.some(query => form.includes(query) || query.includes(form)))) return 'similar-word'; + const rawForms = vernacularWsIds.flatMap(wsId => [entry.lexemeForm?.[wsId], entry.citationForm?.[wsId]]) + .filter((form): form is string => !!form); + if (rawForms.length) { + for (const query of vernQueries) { + const forms = rawForms.map(form => normalizeForCompare(form, query.keepDiacritics)); + if (query.variants.some(variant => forms.includes(variant))) return 'same-word'; + } + for (const query of vernQueries) { + const forms = rawForms.map(form => normalizeForCompare(form, query.keepDiacritics)); + if (query.variants.some(variant => forms.some(form => form.includes(variant) || variant.includes(form)))) { + return 'similar-word'; + } + } } if (analysisQueries.length) { - const glosses = (entry.senses ?? []) - .flatMap(sense => analysisWsIds.map(ws => sense.gloss?.[ws])) - .filter((gloss): gloss is string => !!gloss) - .map(normalizeForCompare); - if (glosses.some(gloss => analysisQueries.some(query => gloss.includes(query) || query.includes(gloss)))) { - return 'same-meaning'; + const rawGlosses = (entry.senses ?? []) + .flatMap(sense => analysisWsIds.map(wsId => sense.gloss?.[wsId])) + .filter((gloss): gloss is string => !!gloss); + for (const query of analysisQueries) { + const queryText = normalizeForCompare(query.text, query.keepDiacritics); + const glosses = rawGlosses.map(gloss => normalizeForCompare(gloss, query.keepDiacritics)); + if (glosses.some(gloss => gloss.includes(queryText) || queryText.includes(gloss))) return 'same-meaning'; } } return 'related'; diff --git a/frontend/viewer/src/locales/en.po b/frontend/viewer/src/locales/en.po index 3947dd229d..95f133697b 100644 --- a/frontend/viewer/src/locales/en.po +++ b/frontend/viewer/src/locales/en.po @@ -97,6 +97,20 @@ msgstr "**Simpler terminology** (e.g. *Word* instead of *Lexeme form*, *Meaning* msgid "A new version of FieldWorks Lite is available." msgstr "A new version of FieldWorks Lite is available." +#. Relevant view: Classic +#. Lite view equivalent: "A similar word already exists" +#. Warning banner in the New Entry dialog when exactly one possible duplicate was found (none an exact headword match) +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "A similar entry already exists" +msgstr "A similar entry already exists" + +#. Relevant view: Lite +#. Classic view equivalent: "A similar entry already exists" +#. Warning banner in the New Word dialog when exactly one possible duplicate was found (none spelled exactly the same) +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "A similar word already exists" +msgstr "A similar word already exists" + #. Relevant view: Lite #. Classic view equivalent: "an entry" #: src/project/browse/BrowseView.svelte diff --git a/frontend/viewer/src/locales/es.po b/frontend/viewer/src/locales/es.po index ba009357e5..4380fbf52b 100644 --- a/frontend/viewer/src/locales/es.po +++ b/frontend/viewer/src/locales/es.po @@ -102,6 +102,20 @@ msgstr "**Terminología más simple** (p. ej. *Palabra* en lugar de *Forma del l msgid "A new version of FieldWorks Lite is available." msgstr "Una nueva versión de FieldWorks Lite está disponible." +#. Relevant view: Classic +#. Lite view equivalent: "A similar word already exists" +#. Warning banner in the New Entry dialog when exactly one possible duplicate was found (none an exact headword match) +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "A similar entry already exists" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "A similar entry already exists" +#. Warning banner in the New Word dialog when exactly one possible duplicate was found (none spelled exactly the same) +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "A similar word already exists" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "an entry" #: src/project/browse/BrowseView.svelte diff --git a/frontend/viewer/src/locales/fr.po b/frontend/viewer/src/locales/fr.po index 4999f369ce..b8b15516d4 100644 --- a/frontend/viewer/src/locales/fr.po +++ b/frontend/viewer/src/locales/fr.po @@ -102,6 +102,20 @@ msgstr "**terminologie plus simple** (par exemple *Mot* au lieu de *forme de lex msgid "A new version of FieldWorks Lite is available." msgstr "Une nouvelle version de FieldWorks Lite est disponible." +#. Relevant view: Classic +#. Lite view equivalent: "A similar word already exists" +#. Warning banner in the New Entry dialog when exactly one possible duplicate was found (none an exact headword match) +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "A similar entry already exists" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "A similar entry already exists" +#. Warning banner in the New Word dialog when exactly one possible duplicate was found (none spelled exactly the same) +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "A similar word already exists" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "an entry" #: src/project/browse/BrowseView.svelte diff --git a/frontend/viewer/src/locales/id.po b/frontend/viewer/src/locales/id.po index 43e40534e4..4afd5c3fee 100644 --- a/frontend/viewer/src/locales/id.po +++ b/frontend/viewer/src/locales/id.po @@ -102,6 +102,20 @@ msgstr "**Terminologi yang lebih sederhana** (mis. *Kata* alih-alih *Bentuk leks msgid "A new version of FieldWorks Lite is available." msgstr "Versi baru FieldWorks Lite telah tersedia." +#. Relevant view: Classic +#. Lite view equivalent: "A similar word already exists" +#. Warning banner in the New Entry dialog when exactly one possible duplicate was found (none an exact headword match) +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "A similar entry already exists" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "A similar entry already exists" +#. Warning banner in the New Word dialog when exactly one possible duplicate was found (none spelled exactly the same) +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "A similar word already exists" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "an entry" #: src/project/browse/BrowseView.svelte diff --git a/frontend/viewer/src/locales/ko.po b/frontend/viewer/src/locales/ko.po index 369b867672..f8f01a4db2 100644 --- a/frontend/viewer/src/locales/ko.po +++ b/frontend/viewer/src/locales/ko.po @@ -102,6 +102,20 @@ msgstr "**간단한 용어 사용** (예: *어휘 형태* 대신 *단어*, *의 msgid "A new version of FieldWorks Lite is available." msgstr "새 버전의 FieldWorks Lite를 사용할 수 있습니다." +#. Relevant view: Classic +#. Lite view equivalent: "A similar word already exists" +#. Warning banner in the New Entry dialog when exactly one possible duplicate was found (none an exact headword match) +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "A similar entry already exists" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "A similar entry already exists" +#. Warning banner in the New Word dialog when exactly one possible duplicate was found (none spelled exactly the same) +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "A similar word already exists" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "an entry" #: src/project/browse/BrowseView.svelte diff --git a/frontend/viewer/src/locales/ms.po b/frontend/viewer/src/locales/ms.po index 817b6cdc61..ae6547d880 100644 --- a/frontend/viewer/src/locales/ms.po +++ b/frontend/viewer/src/locales/ms.po @@ -102,6 +102,20 @@ msgstr "**Terminologi lebih mudah** (cth. *Perkataan* bukan *bentuk leksem*, *Ma msgid "A new version of FieldWorks Lite is available." msgstr "Versi baharu FieldWorks Lite tersedia." +#. Relevant view: Classic +#. Lite view equivalent: "A similar word already exists" +#. Warning banner in the New Entry dialog when exactly one possible duplicate was found (none an exact headword match) +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "A similar entry already exists" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "A similar entry already exists" +#. Warning banner in the New Word dialog when exactly one possible duplicate was found (none spelled exactly the same) +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "A similar word already exists" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "an entry" #: src/project/browse/BrowseView.svelte diff --git a/frontend/viewer/src/locales/sw.po b/frontend/viewer/src/locales/sw.po index 8b8c59754e..b0e93787ea 100644 --- a/frontend/viewer/src/locales/sw.po +++ b/frontend/viewer/src/locales/sw.po @@ -102,6 +102,20 @@ msgstr "**Istilahi rahisi** (kwa mfano *Neno* badala ya *Lexeme form*, *Maana* b msgid "A new version of FieldWorks Lite is available." msgstr "Toleo jipya la FieldWorks Lite linapatikana." +#. Relevant view: Classic +#. Lite view equivalent: "A similar word already exists" +#. Warning banner in the New Entry dialog when exactly one possible duplicate was found (none an exact headword match) +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "A similar entry already exists" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "A similar entry already exists" +#. Warning banner in the New Word dialog when exactly one possible duplicate was found (none spelled exactly the same) +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "A similar word already exists" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "an entry" #: src/project/browse/BrowseView.svelte diff --git a/frontend/viewer/src/locales/vi.po b/frontend/viewer/src/locales/vi.po index 6828556963..ab3c48d631 100644 --- a/frontend/viewer/src/locales/vi.po +++ b/frontend/viewer/src/locales/vi.po @@ -102,6 +102,20 @@ msgstr "**Thuật ngữ đơn giản hơn** (ví dụ *Từ* thay vì *Hình th msgid "A new version of FieldWorks Lite is available." msgstr "Đã có phiên bản mới của FieldWorks Lite." +#. Relevant view: Classic +#. Lite view equivalent: "A similar word already exists" +#. Warning banner in the New Entry dialog when exactly one possible duplicate was found (none an exact headword match) +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "A similar entry already exists" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "A similar entry already exists" +#. Warning banner in the New Word dialog when exactly one possible duplicate was found (none spelled exactly the same) +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "A similar word already exists" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "an entry" #: src/project/browse/BrowseView.svelte From 92321ddda29f161710c695717e19d69b67319437 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Tue, 7 Jul 2026 20:23:52 +0200 Subject: [PATCH 4/4] Expand duplicate rows in place and add an out-of-view jump pill - match rows expand to reveal Add sense / Go to entry; DictionaryEntry gains an inline mode for the compact collapsed rows - classification: attribute same-word matches to the field that hit (Same headword vs Same lexeme form), rank similar words closest-in-length first, drop cross-field coincidences instead of a vague 'related' kind - classify in a $derived so the lazy morph-types resource warms at mount and re-classifies without re-searching - dedupe queries per field kind: the same text typed as lexeme and gloss keeps its gloss query (same-meaning matches were lost) - share the banner message/tint/Enter-trap between the strip and the extracted DuplicateSummaryPill - scope backend-parity doc claims to the CRDT FTS path; drop unverified FLEx attribution - adapt the Playwright suite to the new interaction model and cover the jump pill (7 tests); pin length-delta boundary, gloss containment direction, and closest-form ranking in unit tests (33; 8/8 targeted mutants killed) Co-Authored-By: Claude Fable 5 --- .../dictionary/DictionaryEntry.svelte | 10 +- .../lib/entry-editor/DuplicateCheck.svelte | 163 ++++++++++++------ .../entry-editor/DuplicateSummaryPill.svelte | 55 ++++++ .../lib/entry-editor/NewEntryDialog.svelte | 53 +++++- .../lib/entry-editor/duplicate-check.test.ts | 100 ++++++++++- .../src/lib/entry-editor/duplicate-check.ts | 125 ++++++++++---- frontend/viewer/src/locales/en.po | 23 +++ frontend/viewer/src/locales/es.po | 23 +++ frontend/viewer/src/locales/fr.po | 23 +++ frontend/viewer/src/locales/id.po | 23 +++ frontend/viewer/src/locales/ko.po | 23 +++ frontend/viewer/src/locales/ms.po | 23 +++ frontend/viewer/src/locales/sw.po | 23 +++ frontend/viewer/src/locales/vi.po | 23 +++ .../viewer/tests/new-entry-duplicates.test.ts | 57 ++++-- 15 files changed, 635 insertions(+), 112 deletions(-) create mode 100644 frontend/viewer/src/lib/entry-editor/DuplicateSummaryPill.svelte diff --git a/frontend/viewer/src/lib/components/dictionary/DictionaryEntry.svelte b/frontend/viewer/src/lib/components/dictionary/DictionaryEntry.svelte index ed35db80fc..cc45467a79 100644 --- a/frontend/viewer/src/lib/components/dictionary/DictionaryEntry.svelte +++ b/frontend/viewer/src/lib/components/dictionary/DictionaryEntry.svelte @@ -15,6 +15,7 @@ headwordClass = '', highlightSenseId = undefined, hideExamples = false, + inline = false, ...restProps }: HTMLAttributes & { entry: IEntry; @@ -24,6 +25,8 @@ headwordClass?: string; highlightSenseId?: string; hideExamples?: boolean; + /** Render senses as one flowing line (no line break per sense) — for compact previews */ + inline?: boolean; } = $props(); $effect(() => { @@ -104,7 +107,12 @@ {#each senses as sense, i (sense.id)} {#if senses.length > 1} -
+ {#if inline} + + {' '} + {:else} +
+ {/if} {/if} {#if senses.length > 1} diff --git a/frontend/viewer/src/lib/entry-editor/DuplicateCheck.svelte b/frontend/viewer/src/lib/entry-editor/DuplicateCheck.svelte index 2892cfd90a..106f8428b9 100644 --- a/frontend/viewer/src/lib/entry-editor/DuplicateCheck.svelte +++ b/frontend/viewer/src/lib/entry-editor/DuplicateCheck.svelte @@ -1,3 +1,15 @@ + + -
+
{#if !matches?.length} {#if (duplicatesResource.loading && hasQueries) || matches}
@@ -159,17 +205,18 @@ userToggled = true} - class="rounded-md border border-amber-600/40 bg-amber-500/10 dark:border-amber-400/40" + class="rounded-md border {duplicateTintClass(hasExactWordMatch)}" > - - - {#if hasExactWordMatch} - {pt($t`This entry may already exist`, $t`This word may already exist`, viewService.currentView)} - {:else if matches.length === 1} - {pt($t`A similar entry already exists`, $t`A similar word already exists`, viewService.currentView)} - {:else} - {pt($t`Similar entries already exist`, $t`Similar words already exist`, viewService.currentView)} + {#if hasExactWordMatch} + + {:else} + + {/if} + + {summaryMessage} + {#if !expanded && previewHeadwords} + — {previewHeadwords} {/if} {#if duplicatesResource.loading} @@ -179,42 +226,54 @@ -
    +
      {#each displayedMatches as match (match.entry.id)} - {@const badge = kindLabel(match.kind)} - {@const goToLabel = pt($t`Go to entry`, $t`Go to word`, viewService.currentView)} - {@const headword = writingSystemService.headword(match.entry)} -
    • + {@const badge = kindLabel(match)} + {@const isExpanded = expandedEntryId === match.entry.id} +
    • - {#if canAddSense} - {@const addSenseLabel = pt($t`Add sense to this entry`, $t`Add meaning to this word`, viewService.currentView)} - + {/if} + +
{/if} {/each} diff --git a/frontend/viewer/src/lib/entry-editor/DuplicateSummaryPill.svelte b/frontend/viewer/src/lib/entry-editor/DuplicateSummaryPill.svelte new file mode 100644 index 0000000000..a98ba537f7 --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/DuplicateSummaryPill.svelte @@ -0,0 +1,55 @@ + + + + +
+
+ + +
+
diff --git a/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte b/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte index e5e84d9b35..3adee62060 100644 --- a/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte +++ b/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte @@ -7,7 +7,7 @@ {#if open} @@ -186,11 +212,16 @@ {/snippet} - + + {pt($t`New Entry`, $t`New Word`, viewService.currentView)} -
+ +
-
- open = false} /> +
+ open = false} />
+ {#if duplicateSummary && !duplicateWidgetVisible && !pillDismissed} + + {/if}
{#if errors.length}
diff --git a/frontend/viewer/src/lib/entry-editor/duplicate-check.test.ts b/frontend/viewer/src/lib/entry-editor/duplicate-check.test.ts index 5836fdc74d..48a87a246d 100644 --- a/frontend/viewer/src/lib/entry-editor/duplicate-check.test.ts +++ b/frontend/viewer/src/lib/entry-editor/duplicate-check.test.ts @@ -134,6 +134,19 @@ describe('duplicateQueries', () => { expect(queries.vernacular).toEqual([{text: 'café', wsId: 'seh'}]); }); + it('keeps a gloss query even when the same text was typed as a vernacular value', () => { + // loanword case: lexeme 'radio' glossed 'radio' — the gloss query must survive so + // same-meaning matches on other entries still classify + const queries = duplicateQueries( + {lexemeForm: {seh: 'radio'}, citationForm: {}}, + {gloss: {en: 'radio'}}, + vernWs, + analysisWs, + ); + expect(queries.vernacular).toEqual([{text: 'radio', wsId: 'seh'}]); + expect(queries.analysis).toEqual(['radio']); + }); + it('ignores values in writing systems outside the given lists', () => { const queries = duplicateQueries( {lexemeForm: {'seh-Zxxx-x-audio': 'clip.wav', seh: 'nyumba'}, citationForm: {}}, @@ -171,12 +184,52 @@ describe('classifyDuplicates', () => { expect(result.map(m => m.kind)).toEqual(['similar-word', 'similar-word']); }); + it('classifies mid-word containment as similar-word, not just starts-with', () => { + // real user story: typing "liman" must surface the existing entry "naliman" + const buried = makeEntry({lexemeForm: {seh: 'kumanyumba'}}); + expect(classifyDuplicates([buried], queries, vernWs, analysisWs)[0]?.kind).toBe('similar-word'); + }); + + it('drops containment matches just past the length-delta cap', () => { + // real user story: typing "uz" must not surface every word containing it; + // 'uzembez' is delta 5 (kept, exactly at the cap) and 'uzembeza' is delta 6 (dropped) + const atCap = makeEntry({lexemeForm: {seh: 'uzembez'}}); + const pastCap = makeEntry({lexemeForm: {seh: 'uzembeza'}}); + const result = classifyDuplicates([atCap, pastCap], vernQueries('uz'), vernWs, analysisWs); + expect(result).toEqual([{entry: atCap, kind: 'similar-word'}]); + }); + + it('sorts similar words closest in length first', () => { + const far = makeEntry({lexemeForm: {seh: 'kumanyumba'}}); + const near = makeEntry({lexemeForm: {seh: 'nyumbazi'}}); + const result = classifyDuplicates([far, near], queries, vernWs, analysisWs); + expect(result.map(m => m.entry.id)).toEqual([near.id, far.id]); + }); + + it('ranks an entry by its closest form when several forms are similar', () => { + // closeViaCitation: lexeme is delta 4 but citation is delta 1 — the citation should rank it + const closeViaCitation = makeEntry({lexemeForm: {seh: 'kunyumbaza'}, citationForm: {seh: 'nyumbaz'}}); + const middling = makeEntry({lexemeForm: {seh: 'nyumbazi'}}); + const result = classifyDuplicates([middling, closeViaCitation], queries, vernWs, analysisWs); + expect(result.map(m => m.entry.id)).toEqual([closeViaCitation.id, middling.id]); + }); + it('treats a typed morph token like the backend: "-aji" is the same word as suffix entry "aji"', () => { const suffixEntry = makeEntry({lexemeForm: {seh: 'aji'}}); const result = classifyDuplicates([suffixEntry], vernQueries('-aji'), vernWs, analysisWs, morphTypes); expect(result[0].kind).toBe('same-word'); }); + it('strips morph tokens for lexeme forms but keeps them for citation forms (backend parity)', () => { + const byLexeme = makeEntry({lexemeForm: {seh: 'aji'}}); + const byCitation = makeEntry({citationForm: {seh: 'aji'}}); + const result = classifyDuplicates([byLexeme, byCitation], vernQueries('-aji'), vernWs, analysisWs, morphTypes); + const kindById = new Map(result.map(m => [m.entry.id, m.kind])); + expect(kindById.get(byLexeme.id)).toBe('same-word'); + // citation form 'aji' is compared against the un-stripped '-aji', so it is not the same word + expect(kindById.get(byCitation.id)).not.toBe('same-word'); + }); + it('is accent-insensitive only when the typed text has no diacritics (backend parity)', () => { const plain = makeEntry({lexemeForm: {seh: 'cafe'}}); const accented = makeEntry({lexemeForm: {seh: 'café'}}); @@ -185,18 +238,54 @@ describe('classifyDuplicates', () => { .toEqual(['same-word', 'same-word']); // typed with diacritics -> accents significant -> only the accented entry matches exactly const result = classifyDuplicates([accented, plain], vernQueries('café'), vernWs, analysisWs); - expect(result[0]).toEqual({entry: accented, kind: 'same-word'}); + expect(result[0]).toEqual({entry: accented, kind: 'same-word', field: 'headword'}); expect(result[1].kind).not.toBe('same-word'); }); + it('attributes a same-word match to the field that hit', () => { + // lexeme 'fuz' + citation 'fuza': the headword shown is 'fuza', so a match on the + // typed 'fuz' is the same entry but NOT the same headword + const viaLexemeOnly = makeEntry({lexemeForm: {seh: 'fuz'}, citationForm: {seh: 'fuza'}}); + const viaCitation = makeEntry({lexemeForm: {seh: 'fu'}, citationForm: {seh: 'fuz'}}); + const lexemeIsHeadword = makeEntry({lexemeForm: {seh: 'fuz'}}); + const result = classifyDuplicates([viaLexemeOnly, viaCitation, lexemeIsHeadword], vernQueries('fuz'), vernWs, analysisWs); + const fieldById = new Map(result.map(m => [m.entry.id, m.field])); + expect(fieldById.get(viaLexemeOnly.id)).toBe('lexeme'); + expect(fieldById.get(viaCitation.id)).toBe('headword'); + expect(fieldById.get(lexemeIsHeadword.id)).toBe('headword'); + }); + + it('prefers a citation-form hit over an earlier lexeme-only hit across queries', () => { + // one entry, two typed values: 'fuz' hits only the lexeme form, 'fuza' hits the + // citation form — the citation hit must win the field attribution + const entry = makeEntry({lexemeForm: {seh: 'fuz'}, citationForm: {seh: 'fuza'}}); + const result = classifyDuplicates([entry], vernQueries('fuz', 'fuza'), vernWs, analysisWs); + expect(result[0]).toEqual({entry, kind: 'same-word', field: 'headword'}); + }); + it('classifies gloss overlap as same-meaning', () => { const entry = withGloss('cabana', 'house'); expect(classifyDuplicates([entry], queries, vernWs, analysisWs)[0].kind).toBe('same-meaning'); }); - it('falls back to related when nothing overlaps directly', () => { + it('classifies partial gloss containment in either direction as same-meaning', () => { + const glossContainsQuery = withGloss('cabana', 'houseboat'); + const queryContainsGloss = withGloss('cabana', 'use'); + const result = classifyDuplicates([glossContainsQuery, queryContainsGloss], queries, vernWs, analysisWs); + expect(result.map(m => m.kind)).toEqual(['same-meaning', 'same-meaning']); + }); + + it('drops candidates that overlap in neither headword nor gloss', () => { + // the full-text search can return an entry via a field we don't classify (e.g. definition); + // it should be dropped, not shown as a vague match const entry = withGloss('cabana', 'dwelling'); - expect(classifyDuplicates([entry], queries, vernWs, analysisWs)[0].kind).toBe('related'); + expect(classifyDuplicates([entry], queries, vernWs, analysisWs)).toEqual([]); + }); + + it('drops a candidate whose gloss equals a typed vernacular value (cross-field coincidence)', () => { + // typing lexeme 'nyumba' must not surface an entry merely because its gloss is 'nyumba' + const entry = withGloss('cabana', 'nyumba'); + expect(classifyDuplicates([entry], vernQueries('nyumba'), vernWs, analysisWs)).toEqual([]); }); it('sorts word matches above meaning matches, preserving relevance order within a kind', () => { @@ -208,9 +297,10 @@ describe('classifyDuplicates', () => { expect(result.map(m => m.entry.id)).toEqual([exact.id, similarA.id, similarB.id, meaning.id]); }); - it('never reports same-word when no vernacular text was typed', () => { + it('never reports a headword match when no vernacular text was typed', () => { + // gloss-only query: an entry matched purely by headword is a cross-field coincidence and dropped const entry = makeEntry({lexemeForm: {seh: 'nyumba'}}); const result = classifyDuplicates([entry], {vernacular: [], analysis: ['house']}, vernWs, analysisWs); - expect(result[0].kind).toBe('related'); + expect(result).toEqual([]); }); }); diff --git a/frontend/viewer/src/lib/entry-editor/duplicate-check.ts b/frontend/viewer/src/lib/entry-editor/duplicate-check.ts index cd29704fe4..f4a324b781 100644 --- a/frontend/viewer/src/lib/entry-editor/duplicate-check.ts +++ b/frontend/viewer/src/lib/entry-editor/duplicate-check.ts @@ -3,15 +3,25 @@ import type {IEntry, IMorphType, ISense} from '$lib/dotnet-types'; export const MIN_QUERY_LENGTH = 2; /** - * Ordered strongest to weakest; `related` covers candidates the backend matched in ways we - * don't classify: cross-field hits (vernacular text matching a gloss or vice versa), matches - * in writing systems outside the given lists, or collation equivalences we don't replicate. + * Ordered strongest to weakest. Candidates the backend's full-text search returned but that + * don't match one of these — a typed vernacular value that only hit a gloss, or a typed gloss + * that only hit a headword — are dropped rather than shown as a vague "related" hit: for a + * duplicate warning those cross-field coincidences are noise, not signal. */ -export type DuplicateMatchKind = 'same-word' | 'similar-word' | 'same-meaning' | 'related'; +export type DuplicateMatchKind = 'same-word' | 'similar-word' | 'same-meaning'; + +/** + * Which field an exact match hit: 'headword' when the citation form matched (or the lexeme + * form of an entry with no citation form — the lexeme IS the headword then), 'lexeme' when + * only the lexeme form matched while a different citation form exists. Same severity either + * way; it only exists so the badge doesn't claim "Same headword" for a lexeme-only match. + */ +export type SameWordField = 'headword' | 'lexeme'; export interface DuplicateMatch { entry: IEntry; kind: DuplicateMatchKind; + field?: SameWordField; } export interface VernacularQuery { @@ -29,18 +39,42 @@ export interface DuplicateQueries { export type MorphTokenSource = Pick; +export function duplicateTintClass(hasExactWordMatch: boolean): string { + return hasExactWordMatch + ? 'border-amber-600/40 bg-amber-500/10 dark:border-amber-400/40' + : 'border-border bg-muted/50'; +} + +/** Hosts submit on Enter; interacting with duplicate UI must never also create the entry. */ +export function trapEnter(event: KeyboardEvent): void { + if (event.key === 'Enter') event.stopPropagation(); +} + +export const MAX_SIMILAR_LENGTH_DELTA = 5; + +/** + * Deliberately broader than a starts-with headword match: mid-word containment must count + * (typing "liman" has to surface an existing "naliman"), so it's containment in either + * direction with no positional threshold. The length-delta cap keeps short fragments from + * matching half the lexicon (typing "uz" must not surface every word containing it). + */ +export function isSimilarWord(a: string, b: string): boolean { + const [shorter, longer] = a.length <= b.length ? [a, b] : [b, a]; + return longer.length - shorter.length <= MAX_SIMILAR_LENGTH_DELTA && longer.includes(shorter); +} + const kindRank: Record = { 'same-word': 0, 'similar-word': 1, 'same-meaning': 2, - 'related': 3, }; /** * Mirrors the backend fold (SqlHelpers.ContainsIgnoreCaseAccents → StringExtensions): * invariant case fold, and diacritics are significant only when the search text itself * contains them — hence the keepDiacritics mode, chosen per query via hasDiacritics(). - * Collation-level equivalences (ß≈ss, ligatures) are not replicated. + * Collation-level equivalences (ß≈ss, ligatures, ICU-ignorable characters like soft hyphens) + * are not replicated. */ export function normalizeForCompare(value: string, keepDiacritics = false): string { const decomposed = value.normalize('NFD'); @@ -81,26 +115,29 @@ export function duplicateQueries( vernacularWsIds: string[], analysisWsIds: string[], ): DuplicateQueries { - const seen = new Set(); + // dedupe per field kind, not across: the same text typed as both lexeme and gloss must + // still produce an analysis query, or its same-meaning matches are never classified + const seenVernacular = new Set(); const vernacular: VernacularQuery[] = []; for (const wsId of vernacularWsIds) { for (const value of [entry.lexemeForm?.[wsId], entry.citationForm?.[wsId]]) { const trimmed = value?.trim(); if (!trimmed) continue; const normalized = normalizeForCompare(trimmed); - if (normalized.length < MIN_QUERY_LENGTH || seen.has(normalized)) continue; - seen.add(normalized); + if (normalized.length < MIN_QUERY_LENGTH || seenVernacular.has(normalized)) continue; + seenVernacular.add(normalized); vernacular.push({text: trimmed, wsId}); } } + const seenAnalysis = new Set(); const analysis: string[] = []; for (const value of analysisWsIds.map(wsId => sense?.gloss?.[wsId])) { const trimmed = value?.trim(); if (!trimmed) continue; const normalized = normalizeForCompare(trimmed); - if (normalized.length < MIN_QUERY_LENGTH || seen.has(normalized)) continue; - seen.add(normalized); + if (normalized.length < MIN_QUERY_LENGTH || seenAnalysis.has(normalized)) continue; + seenAnalysis.add(normalized); analysis.push(trimmed); } return {vernacular, analysis}; @@ -117,13 +154,16 @@ export function mergeSearchResults(results: IEntry[][]): IEntry[] { } /** - * Classifies search results against what the user typed and orders them strongest match first - * (headword matches before gloss matches). `candidates` are expected in search-relevance order, - * which is preserved within each kind. + * Classifies search results against what the user typed, drops cross-field coincidences, and + * orders survivors strongest match first (headword matches before gloss matches; similar words + * closest in length first). `candidates` are expected in search-relevance order, which is + * preserved between equally-strong matches. * - * Same-word is deliberately broader than the backend's headwordMatches ranking: any lexeme or - * citation form in any given vernacular WS counts, and typed morph tokens (e.g. "-a" on a - * suffix) are stripped the way the backend strips them before matching lexeme forms. + * Match rules mirror the CRDT FTS backend (EntrySearchService): a typed value counts against a + * lexeme or citation form in any vernacular WS, but typed morph tokens (e.g. the "-" on a suffix) + * are stripped before comparing against lexeme forms and kept when comparing against citation + * forms. The FwData search and the backend's short-query (< 3 chars) fallback never strip morph + * tokens — that only affects which candidates arrive, not how they're classified here. */ export function classifyDuplicates( candidates: IEntry[], @@ -136,10 +176,8 @@ export function classifyDuplicates( const keepDiacritics = hasDiacritics(text); return { keepDiacritics, - variants: [...new Set([ - normalizeForCompare(text, keepDiacritics), - normalizeForCompare(stripMorphTokens(text, morphTypes), keepDiacritics), - ])], + lexeme: normalizeForCompare(stripMorphTokens(text, morphTypes), keepDiacritics), + citation: normalizeForCompare(text, keepDiacritics), }; }); const analysisQueries = queries.analysis.map(text => ({ @@ -147,20 +185,35 @@ export function classifyDuplicates( text, })); - function classify(entry: IEntry): DuplicateMatchKind { - const rawForms = vernacularWsIds.flatMap(wsId => [entry.lexemeForm?.[wsId], entry.citationForm?.[wsId]]) - .filter((form): form is string => !!form); - if (rawForms.length) { + function classify(entry: IEntry): {kind: DuplicateMatchKind, field?: SameWordField, lengthDelta: number} | undefined { + const lexemeForms = vernacularWsIds.map(wsId => entry.lexemeForm?.[wsId]).filter((form): form is string => !!form); + const citationForms = vernacularWsIds.map(wsId => entry.citationForm?.[wsId]).filter((form): form is string => !!form); + if (lexemeForms.length || citationForms.length) { + let sameWordField: SameWordField | undefined; for (const query of vernQueries) { - const forms = rawForms.map(form => normalizeForCompare(form, query.keepDiacritics)); - if (query.variants.some(variant => forms.includes(variant))) return 'same-word'; + const lex = lexemeForms.map(form => normalizeForCompare(form, query.keepDiacritics)); + const cit = citationForms.map(form => normalizeForCompare(form, query.keepDiacritics)); + if (cit.includes(query.citation)) { + sameWordField = 'headword'; + break; + } + if (lex.includes(query.lexeme)) sameWordField ??= citationForms.length ? 'lexeme' : 'headword'; } + if (sameWordField) return {kind: 'same-word', field: sameWordField, lengthDelta: 0}; + let lengthDelta = Infinity; for (const query of vernQueries) { - const forms = rawForms.map(form => normalizeForCompare(form, query.keepDiacritics)); - if (query.variants.some(variant => forms.some(form => form.includes(variant) || variant.includes(form)))) { - return 'similar-word'; + const lex = lexemeForms.map(form => normalizeForCompare(form, query.keepDiacritics)); + const cit = citationForms.map(form => normalizeForCompare(form, query.keepDiacritics)); + for (const {form, queryText} of [ + ...lex.map(form => ({form, queryText: query.lexeme})), + ...cit.map(form => ({form, queryText: query.citation})), + ]) { + if (isSimilarWord(form, queryText)) { + lengthDelta = Math.min(lengthDelta, Math.abs(form.length - queryText.length)); + } } } + if (lengthDelta !== Infinity) return {kind: 'similar-word', lengthDelta}; } if (analysisQueries.length) { const rawGlosses = (entry.senses ?? []) @@ -169,13 +222,17 @@ export function classifyDuplicates( for (const query of analysisQueries) { const queryText = normalizeForCompare(query.text, query.keepDiacritics); const glosses = rawGlosses.map(gloss => normalizeForCompare(gloss, query.keepDiacritics)); - if (glosses.some(gloss => gloss.includes(queryText) || queryText.includes(gloss))) return 'same-meaning'; + if (glosses.some(gloss => gloss.includes(queryText) || queryText.includes(gloss))) { + return {kind: 'same-meaning', lengthDelta: 0}; + } } } - return 'related'; + return undefined; } return candidates - .map(entry => ({entry, kind: classify(entry)})) - .sort((a, b) => kindRank[a.kind] - kindRank[b.kind]); + .map(entry => ({entry, match: classify(entry)})) + .filter((candidate): candidate is {entry: IEntry, match: NonNullable>} => candidate.match !== undefined) + .sort((a, b) => (kindRank[a.match.kind] - kindRank[b.match.kind]) || (a.match.lengthDelta - b.match.lengthDelta)) + .map(({entry, match}) => ({entry, kind: match.kind, field: match.field})); } diff --git a/frontend/viewer/src/locales/en.po b/frontend/viewer/src/locales/en.po index 95f133697b..e79e2305a9 100644 --- a/frontend/viewer/src/locales/en.po +++ b/frontend/viewer/src/locales/en.po @@ -161,6 +161,13 @@ msgstr "Add component" msgid "Add Example" msgstr "Add Example" +#. Relevant view: Lite +#. Classic view equivalent: "Add sense" +#. Short button label on a possible-duplicate result in the New Word dialog; fuller form: "Add meaning to this word" +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add meaning" +msgstr "Add meaning" + #. Button label #. Relevant view: Lite #. Classic view equivalent: "Add Sense" @@ -189,6 +196,13 @@ msgstr "Add new word" msgid "Add part of" msgstr "Add part of" +#. Relevant view: Classic +#. Lite view equivalent: "Add meaning" +#. Short button label on a possible-duplicate result in the New Entry dialog; fuller form: "Add sense to this entry" +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add sense" +msgstr "Add sense" + #. Relevant view: Classic #. Lite view equivalent: "Add Meaning" #. Small button to add another sense/meaning for a word @@ -440,6 +454,7 @@ msgstr "clear" #: src/lib/about/AboutDialog.svelte #: src/lib/components/field-editors/multi-select.svelte #: src/lib/components/ui/button/x-button.svelte +#: src/lib/entry-editor/DuplicateSummaryPill.svelte #: src/project/tasks/SubjectPopup.svelte msgid "Close" msgstr "Close" @@ -1825,10 +1840,18 @@ msgstr "rose" msgid "Same headword" msgstr "Same headword" +#. Relevant view: Classic +#. Lite view equivalent: "Same word" +#. Badge on a possible-duplicate result: only its lexeme form matches the typed text, while its citation form (shown as the headword) differs — deliberately NOT claiming "Same headword" +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Same lexeme form" +msgstr "Same lexeme form" + #. Relevant view: Lite #. Classic view equivalent: "Same headword" #. Badge on a possible-duplicate result: it is spelled exactly like the word being typed in the New Word dialog #: src/lib/entry-editor/DuplicateCheck.svelte +#: src/lib/entry-editor/DuplicateCheck.svelte msgid "Same word" msgstr "Same word" diff --git a/frontend/viewer/src/locales/es.po b/frontend/viewer/src/locales/es.po index 4380fbf52b..e44011f40c 100644 --- a/frontend/viewer/src/locales/es.po +++ b/frontend/viewer/src/locales/es.po @@ -166,6 +166,13 @@ msgstr "Añadir componente" msgid "Add Example" msgstr "Añadir ejemplo" +#. Relevant view: Lite +#. Classic view equivalent: "Add sense" +#. Short button label on a possible-duplicate result in the New Word dialog; fuller form: "Add meaning to this word" +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add meaning" +msgstr "" + #. Button label #. Relevant view: Lite #. Classic view equivalent: "Add Sense" @@ -194,6 +201,13 @@ msgstr "Añadir nueva palabra" msgid "Add part of" msgstr "Añadir parte de" +#. Relevant view: Classic +#. Lite view equivalent: "Add meaning" +#. Short button label on a possible-duplicate result in the New Entry dialog; fuller form: "Add sense to this entry" +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add sense" +msgstr "" + #. Relevant view: Classic #. Lite view equivalent: "Add Meaning" #. Small button to add another sense/meaning for a word @@ -445,6 +459,7 @@ msgstr "borrar" #: src/lib/about/AboutDialog.svelte #: src/lib/components/field-editors/multi-select.svelte #: src/lib/components/ui/button/x-button.svelte +#: src/lib/entry-editor/DuplicateSummaryPill.svelte #: src/project/tasks/SubjectPopup.svelte msgid "Close" msgstr "Cerrar" @@ -1830,10 +1845,18 @@ msgstr "rosa" msgid "Same headword" msgstr "" +#. Relevant view: Classic +#. Lite view equivalent: "Same word" +#. Badge on a possible-duplicate result: only its lexeme form matches the typed text, while its citation form (shown as the headword) differs — deliberately NOT claiming "Same headword" +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Same lexeme form" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "Same headword" #. Badge on a possible-duplicate result: it is spelled exactly like the word being typed in the New Word dialog #: src/lib/entry-editor/DuplicateCheck.svelte +#: src/lib/entry-editor/DuplicateCheck.svelte msgid "Same word" msgstr "" diff --git a/frontend/viewer/src/locales/fr.po b/frontend/viewer/src/locales/fr.po index b8b15516d4..766a4b7b65 100644 --- a/frontend/viewer/src/locales/fr.po +++ b/frontend/viewer/src/locales/fr.po @@ -166,6 +166,13 @@ msgstr "Ajouter un composant" msgid "Add Example" msgstr "Ajouter un exemple" +#. Relevant view: Lite +#. Classic view equivalent: "Add sense" +#. Short button label on a possible-duplicate result in the New Word dialog; fuller form: "Add meaning to this word" +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add meaning" +msgstr "" + #. Button label #. Relevant view: Lite #. Classic view equivalent: "Add Sense" @@ -194,6 +201,13 @@ msgstr "Ajouter un nouveau mot" msgid "Add part of" msgstr "Ajouter une partie de" +#. Relevant view: Classic +#. Lite view equivalent: "Add meaning" +#. Short button label on a possible-duplicate result in the New Entry dialog; fuller form: "Add sense to this entry" +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add sense" +msgstr "" + #. Relevant view: Classic #. Lite view equivalent: "Add Meaning" #. Small button to add another sense/meaning for a word @@ -445,6 +459,7 @@ msgstr "effacer" #: src/lib/about/AboutDialog.svelte #: src/lib/components/field-editors/multi-select.svelte #: src/lib/components/ui/button/x-button.svelte +#: src/lib/entry-editor/DuplicateSummaryPill.svelte #: src/project/tasks/SubjectPopup.svelte msgid "Close" msgstr "Fermer" @@ -1830,10 +1845,18 @@ msgstr "rose" msgid "Same headword" msgstr "" +#. Relevant view: Classic +#. Lite view equivalent: "Same word" +#. Badge on a possible-duplicate result: only its lexeme form matches the typed text, while its citation form (shown as the headword) differs — deliberately NOT claiming "Same headword" +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Same lexeme form" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "Same headword" #. Badge on a possible-duplicate result: it is spelled exactly like the word being typed in the New Word dialog #: src/lib/entry-editor/DuplicateCheck.svelte +#: src/lib/entry-editor/DuplicateCheck.svelte msgid "Same word" msgstr "" diff --git a/frontend/viewer/src/locales/id.po b/frontend/viewer/src/locales/id.po index 4afd5c3fee..ffe78ce740 100644 --- a/frontend/viewer/src/locales/id.po +++ b/frontend/viewer/src/locales/id.po @@ -166,6 +166,13 @@ msgstr "Menambahkan komponen" msgid "Add Example" msgstr "Tambahkan Contoh" +#. Relevant view: Lite +#. Classic view equivalent: "Add sense" +#. Short button label on a possible-duplicate result in the New Word dialog; fuller form: "Add meaning to this word" +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add meaning" +msgstr "" + #. Button label #. Relevant view: Lite #. Classic view equivalent: "Add Sense" @@ -194,6 +201,13 @@ msgstr "Tambahkan kata baru" msgid "Add part of" msgstr "Tambahkan bagian dari" +#. Relevant view: Classic +#. Lite view equivalent: "Add meaning" +#. Short button label on a possible-duplicate result in the New Entry dialog; fuller form: "Add sense to this entry" +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add sense" +msgstr "" + #. Relevant view: Classic #. Lite view equivalent: "Add Meaning" #. Small button to add another sense/meaning for a word @@ -445,6 +459,7 @@ msgstr "jelas" #: src/lib/about/AboutDialog.svelte #: src/lib/components/field-editors/multi-select.svelte #: src/lib/components/ui/button/x-button.svelte +#: src/lib/entry-editor/DuplicateSummaryPill.svelte #: src/project/tasks/SubjectPopup.svelte msgid "Close" msgstr "Tutup" @@ -1830,10 +1845,18 @@ msgstr "naik" msgid "Same headword" msgstr "" +#. Relevant view: Classic +#. Lite view equivalent: "Same word" +#. Badge on a possible-duplicate result: only its lexeme form matches the typed text, while its citation form (shown as the headword) differs — deliberately NOT claiming "Same headword" +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Same lexeme form" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "Same headword" #. Badge on a possible-duplicate result: it is spelled exactly like the word being typed in the New Word dialog #: src/lib/entry-editor/DuplicateCheck.svelte +#: src/lib/entry-editor/DuplicateCheck.svelte msgid "Same word" msgstr "" diff --git a/frontend/viewer/src/locales/ko.po b/frontend/viewer/src/locales/ko.po index f8f01a4db2..5aa1687524 100644 --- a/frontend/viewer/src/locales/ko.po +++ b/frontend/viewer/src/locales/ko.po @@ -166,6 +166,13 @@ msgstr "구성 요소 추가" msgid "Add Example" msgstr "예제 추가" +#. Relevant view: Lite +#. Classic view equivalent: "Add sense" +#. Short button label on a possible-duplicate result in the New Word dialog; fuller form: "Add meaning to this word" +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add meaning" +msgstr "" + #. Button label #. Relevant view: Lite #. Classic view equivalent: "Add Sense" @@ -194,6 +201,13 @@ msgstr "새 단어 추가" msgid "Add part of" msgstr "의 일부를 추가합니다." +#. Relevant view: Classic +#. Lite view equivalent: "Add meaning" +#. Short button label on a possible-duplicate result in the New Entry dialog; fuller form: "Add sense to this entry" +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add sense" +msgstr "" + #. Relevant view: Classic #. Lite view equivalent: "Add Meaning" #. Small button to add another sense/meaning for a word @@ -445,6 +459,7 @@ msgstr "clear" #: src/lib/about/AboutDialog.svelte #: src/lib/components/field-editors/multi-select.svelte #: src/lib/components/ui/button/x-button.svelte +#: src/lib/entry-editor/DuplicateSummaryPill.svelte #: src/project/tasks/SubjectPopup.svelte msgid "Close" msgstr "닫기" @@ -1830,10 +1845,18 @@ msgstr "rose" msgid "Same headword" msgstr "" +#. Relevant view: Classic +#. Lite view equivalent: "Same word" +#. Badge on a possible-duplicate result: only its lexeme form matches the typed text, while its citation form (shown as the headword) differs — deliberately NOT claiming "Same headword" +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Same lexeme form" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "Same headword" #. Badge on a possible-duplicate result: it is spelled exactly like the word being typed in the New Word dialog #: src/lib/entry-editor/DuplicateCheck.svelte +#: src/lib/entry-editor/DuplicateCheck.svelte msgid "Same word" msgstr "" diff --git a/frontend/viewer/src/locales/ms.po b/frontend/viewer/src/locales/ms.po index ae6547d880..f125a82356 100644 --- a/frontend/viewer/src/locales/ms.po +++ b/frontend/viewer/src/locales/ms.po @@ -166,6 +166,13 @@ msgstr "Tambah komponen" msgid "Add Example" msgstr "Tambah Contoh" +#. Relevant view: Lite +#. Classic view equivalent: "Add sense" +#. Short button label on a possible-duplicate result in the New Word dialog; fuller form: "Add meaning to this word" +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add meaning" +msgstr "" + #. Button label #. Relevant view: Lite #. Classic view equivalent: "Add Sense" @@ -194,6 +201,13 @@ msgstr "Tambah perkataan baharu" msgid "Add part of" msgstr "Tambah sebahagian daripada" +#. Relevant view: Classic +#. Lite view equivalent: "Add meaning" +#. Short button label on a possible-duplicate result in the New Entry dialog; fuller form: "Add sense to this entry" +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add sense" +msgstr "" + #. Relevant view: Classic #. Lite view equivalent: "Add Meaning" #. Small button to add another sense/meaning for a word @@ -445,6 +459,7 @@ msgstr "padam" #: src/lib/about/AboutDialog.svelte #: src/lib/components/field-editors/multi-select.svelte #: src/lib/components/ui/button/x-button.svelte +#: src/lib/entry-editor/DuplicateSummaryPill.svelte #: src/project/tasks/SubjectPopup.svelte msgid "Close" msgstr "Tutup" @@ -1830,10 +1845,18 @@ msgstr "merah jambu" msgid "Same headword" msgstr "" +#. Relevant view: Classic +#. Lite view equivalent: "Same word" +#. Badge on a possible-duplicate result: only its lexeme form matches the typed text, while its citation form (shown as the headword) differs — deliberately NOT claiming "Same headword" +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Same lexeme form" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "Same headword" #. Badge on a possible-duplicate result: it is spelled exactly like the word being typed in the New Word dialog #: src/lib/entry-editor/DuplicateCheck.svelte +#: src/lib/entry-editor/DuplicateCheck.svelte msgid "Same word" msgstr "" diff --git a/frontend/viewer/src/locales/sw.po b/frontend/viewer/src/locales/sw.po index b0e93787ea..3c1a2a7600 100644 --- a/frontend/viewer/src/locales/sw.po +++ b/frontend/viewer/src/locales/sw.po @@ -166,6 +166,13 @@ msgstr "Ongeza sehemu" msgid "Add Example" msgstr "Ongeza Mfano" +#. Relevant view: Lite +#. Classic view equivalent: "Add sense" +#. Short button label on a possible-duplicate result in the New Word dialog; fuller form: "Add meaning to this word" +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add meaning" +msgstr "" + #. Button label #. Relevant view: Lite #. Classic view equivalent: "Add Sense" @@ -194,6 +201,13 @@ msgstr "Ongeza neno jipya" msgid "Add part of" msgstr "Ongeza sehemu ya" +#. Relevant view: Classic +#. Lite view equivalent: "Add meaning" +#. Short button label on a possible-duplicate result in the New Entry dialog; fuller form: "Add sense to this entry" +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add sense" +msgstr "" + #. Relevant view: Classic #. Lite view equivalent: "Add Meaning" #. Small button to add another sense/meaning for a word @@ -445,6 +459,7 @@ msgstr "futa" #: src/lib/about/AboutDialog.svelte #: src/lib/components/field-editors/multi-select.svelte #: src/lib/components/ui/button/x-button.svelte +#: src/lib/entry-editor/DuplicateSummaryPill.svelte #: src/project/tasks/SubjectPopup.svelte msgid "Close" msgstr "Funga" @@ -1830,10 +1845,18 @@ msgstr "waridi" msgid "Same headword" msgstr "" +#. Relevant view: Classic +#. Lite view equivalent: "Same word" +#. Badge on a possible-duplicate result: only its lexeme form matches the typed text, while its citation form (shown as the headword) differs — deliberately NOT claiming "Same headword" +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Same lexeme form" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "Same headword" #. Badge on a possible-duplicate result: it is spelled exactly like the word being typed in the New Word dialog #: src/lib/entry-editor/DuplicateCheck.svelte +#: src/lib/entry-editor/DuplicateCheck.svelte msgid "Same word" msgstr "" diff --git a/frontend/viewer/src/locales/vi.po b/frontend/viewer/src/locales/vi.po index ab3c48d631..3232bc8e0f 100644 --- a/frontend/viewer/src/locales/vi.po +++ b/frontend/viewer/src/locales/vi.po @@ -166,6 +166,13 @@ msgstr "Thêm thành phần" msgid "Add Example" msgstr "Thêm ví dụ" +#. Relevant view: Lite +#. Classic view equivalent: "Add sense" +#. Short button label on a possible-duplicate result in the New Word dialog; fuller form: "Add meaning to this word" +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add meaning" +msgstr "" + #. Button label #. Relevant view: Lite #. Classic view equivalent: "Add Sense" @@ -194,6 +201,13 @@ msgstr "Thêm từ mới" msgid "Add part of" msgstr "Thêm phần của" +#. Relevant view: Classic +#. Lite view equivalent: "Add meaning" +#. Short button label on a possible-duplicate result in the New Entry dialog; fuller form: "Add sense to this entry" +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Add sense" +msgstr "" + #. Relevant view: Classic #. Lite view equivalent: "Add Meaning" #. Small button to add another sense/meaning for a word @@ -445,6 +459,7 @@ msgstr "xóa" #: src/lib/about/AboutDialog.svelte #: src/lib/components/field-editors/multi-select.svelte #: src/lib/components/ui/button/x-button.svelte +#: src/lib/entry-editor/DuplicateSummaryPill.svelte #: src/project/tasks/SubjectPopup.svelte msgid "Close" msgstr "Đóng" @@ -1830,10 +1845,18 @@ msgstr "hoa hồng" msgid "Same headword" msgstr "" +#. Relevant view: Classic +#. Lite view equivalent: "Same word" +#. Badge on a possible-duplicate result: only its lexeme form matches the typed text, while its citation form (shown as the headword) differs — deliberately NOT claiming "Same headword" +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Same lexeme form" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "Same headword" #. Badge on a possible-duplicate result: it is spelled exactly like the word being typed in the New Word dialog #: src/lib/entry-editor/DuplicateCheck.svelte +#: src/lib/entry-editor/DuplicateCheck.svelte msgid "Same word" msgstr "" diff --git a/frontend/viewer/tests/new-entry-duplicates.test.ts b/frontend/viewer/tests/new-entry-duplicates.test.ts index 6323af6fbe..205ed1f3dc 100644 --- a/frontend/viewer/tests/new-entry-duplicates.test.ts +++ b/frontend/viewer/tests/new-entry-duplicates.test.ts @@ -4,7 +4,8 @@ import {BrowsePage} from './browse-page'; /** * Tests for the possible-duplicates check in the new entry dialog: * typing an existing word/gloss surfaces similar entries, a brand-new word - * gets the "no similar entries" indicator, and a duplicate can be opened. + * gets the "no similar entries" indicator, and a match row expands in place + * to offer "Go to entry" / "Add sense". */ // demo data (see demo-entry-data.ts): entry 'baba' exists, glossed 'father' @@ -29,8 +30,14 @@ function glossInput(dialog: Locator): Locator { const duplicatesSummary = /already exist/i; const newWordIndicator = /no similar entries found|looks like a new word/i; +/** The duplicate strip's header — .first() because the jump pill repeats the message when the strip is out of view. */ +function stripSummary(dialog: Locator): Locator { + return dialog.getByText(duplicatesSummary).first(); +} + +/** The visible match rows — each expands in place (aria-expanded) to reveal its actions; the collapsed strip keeps them in the DOM hidden. */ function duplicateRows(dialog: Locator): Locator { - return dialog.getByRole('button', {name: /^go to (entry|word)/i}); + return dialog.locator('li > button[aria-expanded]:visible'); } test.describe('New entry possible duplicates', () => { @@ -42,12 +49,13 @@ test.describe('New entry possible duplicates', () => { await lexemeInput(dialog).fill(existingLexeme); // exact headword match => attention strip + auto-expanded list - await expect(dialog.getByText(duplicatesSummary)).toBeVisible(); + await expect(stripSummary(dialog)).toBeVisible(); // 'baba' is a substring of 'ubaba', so both rows match — .first() is the exact match because same-word sorts first const duplicateRow = duplicateRows(dialog).filter({hasText: existingLexeme}).first(); - await expect(duplicateRow).toBeVisible(); - await duplicateRow.click(); + await expect(duplicateRow).toHaveAttribute('aria-expanded', 'true'); + + await dialog.getByRole('button', {name: /go to (entry|word)/i}).click(); await expect(dialog).toBeHidden(); await expect(page).toHaveURL(/entryId=/); const openedLexeme = await browsePage.entryView.getLexemeInput(); @@ -61,7 +69,7 @@ test.describe('New entry possible duplicates', () => { const dialog = await openNewEntryDialog(page); await lexemeInput(dialog).fill('zyzzyvazz'); await expect(dialog.getByText(newWordIndicator)).toBeVisible(); - await expect(dialog.getByText(duplicatesSummary)).toBeHidden(); + await expect(stripSummary(dialog)).toBeHidden(); }); test('typed meaning can be added to an existing entry instead', async ({page}) => { @@ -73,9 +81,10 @@ test.describe('New entry possible duplicates', () => { const newGloss = `rescued-${Date.now().toString().slice(-6)}`; await glossInput(dialog).fill(newGloss); - // exact match auto-expands the list - const row = dialog.locator('li').filter({hasText: existingLexeme}).first(); - await row.getByRole('button', {name: /add (sense|meaning)/i}).click(); + // exact match auto-expands the list; expand the row to reach its actions + const duplicateRow = duplicateRows(dialog).filter({hasText: existingLexeme}).first(); + await duplicateRow.click(); + await dialog.getByRole('button', {name: /add (sense|meaning)/i}).click(); await expect(dialog).toBeHidden(); await expect(page).toHaveURL(/entryId=/); @@ -94,7 +103,7 @@ test.describe('New entry possible duplicates', () => { // substring of 'balalika' only — no exact match, so the strip stays collapsed await lexemeInput(dialog).fill('balal'); - const summary = dialog.getByText(duplicatesSummary); + const summary = stripSummary(dialog); await expect(summary).toBeVisible(); await expect(duplicateRows(dialog)).toHaveCount(0); await summary.click(); @@ -108,13 +117,14 @@ test.describe('New entry possible duplicates', () => { const dialog = await openNewEntryDialog(page); await lexemeInput(dialog).fill('ba'); - const summary = dialog.getByText(duplicatesSummary); + const summary = stripSummary(dialog); await expect(summary).toBeVisible(); - await expect(dialog.getByText('10+')).toBeVisible(); + // dozens of demo lexemes contain 'ba', so the fetch cap is hit and the count renders as 'N+' + await expect(dialog.getByText(/^\d+\+$/).first()).toBeVisible(); const rows = duplicateRows(dialog); if (await rows.count() === 0) await summary.click(); // expands automatically only on an exact match - // 3 = the component's initial display count; '10+' assumes the demo data has >10 entries containing 'ba' + // 3 = the component's initial display count await expect(rows).toHaveCount(3); await dialog.getByRole('button', {name: /show \d+ more/i}).click(); expect(await rows.count()).toBeGreaterThan(3); @@ -128,10 +138,29 @@ test.describe('New entry possible duplicates', () => { await lexemeInput(dialog).fill('zyzzyvazz'); await glossInput(dialog).fill(existingGloss); - const summary = dialog.getByText(duplicatesSummary); + const summary = stripSummary(dialog); await expect(summary).toBeVisible(); // gloss-only matches don't auto-expand; expand to see the badge await summary.click(); await expect(dialog.getByText(/similar (gloss|meaning)/i).first()).toBeVisible(); }); + + test('an out-of-view duplicate strip surfaces a jump pill', async ({page}) => { + // small viewport so the duplicate strip (below the editor grid) starts outside the dialog's scroll view + await page.setViewportSize({width: 1024, height: 560}); + const browsePage = new BrowsePage(page); + await browsePage.goto(); + + const dialog = await openNewEntryDialog(page); + await lexemeInput(dialog).fill(existingLexeme); + + // the pill's accessible name is exactly the summary message; the strip trigger's name has more text + const pill = dialog.getByRole('button', {name: /^this (entry|word) may already exist$/i}); + await expect(pill).toBeVisible(); + await pill.click(); + + // jumping scrolls the strip into view, which dismisses the pill and shows the match rows + await expect(duplicateRows(dialog).first()).toBeInViewport(); + await expect(pill).toBeHidden(); + }); });