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 new file mode 100644 index 0000000000..a79f9b5475 --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/DuplicateCheck.svelte @@ -0,0 +1,312 @@ + + + + +
+ {#if !matches?.length} + {#if (duplicatesResource.loading && hasQueries) || matches || duplicatesResource.error} +
+ {#if duplicatesResource.loading} + + {pt($t`Checking for similar entries…`, $t`Checking for similar words…`, viewService.currentView)} + {:else if duplicatesResource.error} + + + {pt($t`Could not check for similar entries`, $t`Could not check 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 {duplicateTintClass(hasExactWordMatch)}" + > + + {#if hasExactWordMatch} + + {:else} + + {/if} + + {summaryMessage} + {#if !expanded && previewHeadwords} + — {previewHeadwords} + {/if} + + {#if duplicatesResource.loading} + + {/if} + {matches.length}{duplicatesResource.current?.capped ? '+' : ''} + + + +
    + {#each displayedMatches as match (match.entry.id)} + {@const badge = kindLabel(match)} + {@const isExpanded = expandedEntryId === match.entry.id} +
  • + + {#if isExpanded} +
    + {#if canAddSense && (match.kind === 'same-word' || match.kind === 'similar-word')} + {@const addSenseLabel = pt($t`Add sense`, $t`Add meaning`, viewService.currentView)} + {@const addSenseHint = pt($t`Add sense to this entry`, $t`Add meaning to this word`, viewService.currentView)} + + {/if} + +
    + {/if} +
  • + {/each} + {#if matches.length > displayedMatches.length} + {@const remainingEntries = matches.length - displayedMatches.length} +
  • + +
  • + {/if} +
+
+
+ {/if} +
diff --git a/frontend/viewer/src/lib/entry-editor/DuplicateCheckSection.svelte b/frontend/viewer/src/lib/entry-editor/DuplicateCheckSection.svelte new file mode 100644 index 0000000000..c8ee5d68bf --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/DuplicateCheckSection.svelte @@ -0,0 +1,58 @@ + + + +
+ +
+{#if duplicateSummary && !duplicateWidgetVisible && !pillDismissed} + +{/if} 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..ec7afc33f7 --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/DuplicateSummaryPill.svelte @@ -0,0 +1,50 @@ + + +
+
+ + +
+
diff --git a/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte b/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte index 32f6b7515a..88b295722f 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 DuplicateCheckSection from './DuplicateCheckSection.svelte'; import EntryEditorPrimitive from './object-editors/EntryEditorPrimitive.svelte'; import ObjectHeader from './object-editors/ObjectHeader.svelte'; import SenseEditorPrimitive from './object-editors/SenseEditorPrimitive.svelte'; @@ -31,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))); @@ -59,21 +61,26 @@ async function createEntry(e: Event) { e.preventDefault(); e.stopPropagation(); + // we might already be creating something (double-Enter or Add sense) + 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([]); @@ -178,11 +185,16 @@ {/snippet} - + + {pt($t`New Entry`, $t`New Word`, viewService.currentView)} -
+ +
+ open = false} />
{#if errors.length}
@@ -221,7 +235,7 @@ {/if} - 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..a318b07681 --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/duplicate-check.test.ts @@ -0,0 +1,375 @@ +import {describe, expect, it, vi} from 'vitest'; +import type {IEntry} from '$lib/dotnet-types'; +import {MorphTypeKind} from '$lib/dotnet-types'; +import { + classifyQueryResults, + getDuplicateEntryQueries, + mergeSearchResults, + normalizeForCompare, + trapEnter, + type DuplicateQueries, + type DuplicateWritingSystems, +} from './duplicate-check'; + +const vernWs = ['seh', 'por']; +const analysisWs = ['en', 'fr']; + +function undecoratedHeadword(entry: IEntry, wsId: string): string | undefined { + return entry.citationForm?.[wsId]?.trim() || entry.lexemeForm?.[wsId]?.trim() || undefined; +} + +// Stands in for writingSystemService.headword: citation form wins, else a suffix's lexeme shows a +// leading token (e.g. "-aji"). Passed to exercise the morph-token classification paths. +function decoratedHeadword(entry: IEntry, wsId: string): string | undefined { + const citation = entry.citationForm?.[wsId]?.trim(); + if (citation) return citation; + const lexeme = entry.lexemeForm?.[wsId]?.trim(); + if (!lexeme) return undefined; + return entry.morphType === MorphTypeKind.Suffix ? `-${lexeme}` : lexeme; +} + +// The WritingSystemService slice classifyQueryResults reads; defaults to the undecorated headword. +function ws(headword = undecoratedHeadword): DuplicateWritingSystems { + return { + vernacularNoAudio: vernWs.map(wsId => ({wsId})), + analysisNoAudio: analysisWs.map(wsId => ({wsId})), + headword, + }; +} + +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]], + }); +} + +// Models a typed vernacular value: `exact` keeps its diacritics, `text` is the stripped form sent +// to the backend. classifyQueryResults re-normalizes both, so passing the raw text for each is fine. +function vernQueries(...texts: string[]): DuplicateQueries { + return {vernacular: texts.map(text => ({text, exact: text, wsId: 'seh'})), analysis: []}; +} + +describe('normalizeForCompare', () => { + 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'); + }); + + it('treats a missing value as empty', () => { + expect(normalizeForCompare(undefined)).toBe(''); + }); +}); + + +describe('trapEnter', () => { + // the host dialog submits on Enter; a leak here silently creates the duplicate being warned about + it('stops Enter from reaching the dialog, letting other keys through', () => { + const enter = new KeyboardEvent('keydown', {key: 'Enter'}); + const enterStop = vi.spyOn(enter, 'stopPropagation'); + trapEnter(enter); + expect(enterStop).toHaveBeenCalledOnce(); + + const space = new KeyboardEvent('keydown', {key: ' '}); + const spaceStop = vi.spyOn(space, 'stopPropagation'); + trapEnter(space); + expect(spaceStop).not.toHaveBeenCalled(); + }); +}); + +describe('getDuplicateEntryQueries', () => { + it('collects vernacular values tagged with their writing system, and gloss texts', () => { + const queries = getDuplicateEntryQueries( + {lexemeForm: {seh: 'nyumba', por: 'casa'}, citationForm: {}}, + {gloss: {en: 'house', fr: ''}}, + vernWs, + analysisWs, + ); + expect(queries.vernacular).toEqual([ + {text: 'nyumba', exact: 'nyumba', wsId: 'seh'}, + {text: 'casa', exact: 'casa', wsId: 'por'}, + ]); + expect(queries.analysis).toEqual(['house']); + }); + + it('skips blank and too-short values', () => { + const queries = getDuplicateEntryQueries( + {lexemeForm: {seh: ' n '}, citationForm: {}}, + {gloss: {en: ' '}}, + vernWs, + analysisWs, + ); + expect(queries.vernacular).toEqual([]); + expect(queries.analysis).toEqual([]); + }); + + it('accepts a value exactly at the length threshold', () => { + const queries = getDuplicateEntryQueries( + {lexemeForm: {seh: 'ba'}, citationForm: {}}, + undefined, + vernWs, + analysisWs, + ); + expect(queries.vernacular).toEqual([{text: 'ba', exact: '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 = getDuplicateEntryQueries( + {lexemeForm: {seh: 'é'}, citationForm: {}}, + undefined, + vernWs, + analysisWs, + ); + expect(queries.vernacular).toEqual([]); + }); + + it('keeps the same text typed in different writing systems as separate queries', () => { + // each search is sorted by its own WS so that WS's headword matches rank first + const queries = getDuplicateEntryQueries( + {lexemeForm: {seh: 'kalata', por: 'kalata'}, citationForm: {}}, + undefined, + vernWs, + analysisWs, + ); + expect(queries.vernacular).toEqual([ + {text: 'kalata', exact: 'kalata', wsId: 'seh'}, + {text: 'kalata', exact: 'kalata', wsId: 'por'}, + ]); + }); + + it('keeps accent variants within a writing system, sharing the stripped query but not the exact form', () => { + // lexeme 'café' + citation 'cafe' both search the backend for 'cafe', but each keeps its own + // exact form so the classifier can mark whichever one the user typed as the same word + const queries = getDuplicateEntryQueries( + {lexemeForm: {seh: 'café'}, citationForm: {seh: 'cafe'}}, + undefined, + vernWs, + analysisWs, + ); + expect(queries.vernacular).toEqual([ + {text: 'cafe', exact: 'café'.normalize('NFD'), wsId: 'seh'}, + {text: 'cafe', exact: 'cafe', wsId: 'seh'}, + ]); + }); + + it('collapses lexeme and citation forms that fold together (identical, or case-only difference)', () => { + const identical = getDuplicateEntryQueries( + {lexemeForm: {seh: 'nyumba'}, citationForm: {seh: 'nyumba'}}, + undefined, + vernWs, + analysisWs, + ); + expect(identical.vernacular).toEqual([{text: 'nyumba', exact: 'nyumba', wsId: 'seh'}]); + + // 'Cafe' and 'cafe' fold to the same form, so they collapse (accent variants would not — see above) + const caseOnly = getDuplicateEntryQueries( + {lexemeForm: {seh: 'Cafe'}, citationForm: {seh: 'cafe'}}, + undefined, + vernWs, + analysisWs, + ); + expect(caseOnly.vernacular).toEqual([{text: 'cafe', exact: 'cafe', 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 = getDuplicateEntryQueries( + {lexemeForm: {seh: 'radio'}, citationForm: {}}, + {gloss: {en: 'radio'}}, + vernWs, + analysisWs, + ); + expect(queries.vernacular).toEqual([{text: 'radio', exact: 'radio', wsId: 'seh'}]); + expect(queries.analysis).toEqual(['radio']); + }); + + it('ignores values in writing systems outside the given lists', () => { + const queries = getDuplicateEntryQueries( + {lexemeForm: {'seh-Zxxx-x-audio': 'clip.wav', seh: 'nyumba'}, citationForm: {}}, + undefined, + vernWs, + analysisWs, + ); + expect(queries.vernacular).toEqual([{text: 'nyumba', exact: 'nyumba', wsId: 'seh'}]); + }); +}); + +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('classifyQueryResults', () => { + const queries: DuplicateQueries = {vernacular: [{text: 'nyumba', exact: 'nyumba', wsId: 'seh'}], analysis: ['house']}; + + it('classifies exact headword matches as same-word, even via citation form or other ws', () => { + // case-only difference still folds to the same word; accent differences are exercised below + const byLexeme = makeEntry({lexemeForm: {seh: 'Nyumba'}}); + const byCitation = makeEntry({citationForm: {por: 'nyumba'}}); + const result = classifyQueryResults([byLexeme, byCitation], queries, ws()); + 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 = classifyQueryResults([superstring, substring], queries, ws()); + 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(classifyQueryResults([buried], queries, ws())[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 = classifyQueryResults([atCap, pastCap], vernQueries('uz'), ws()); + 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 = classifyQueryResults([far, near], queries, ws()); + 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 = classifyQueryResults([middling, closeViaCitation], queries, ws()); + expect(result.map(m => m.entry.id)).toEqual([closeViaCitation.id, middling.id]); + }); + + it('matches a typed morph token against the decorated headword: "-aji" is the suffix entry "aji"', () => { + const suffixEntry = makeEntry({lexemeForm: {seh: 'aji'}, morphType: MorphTypeKind.Suffix}); + const result = classifyQueryResults([suffixEntry], vernQueries('-aji'), ws(decoratedHeadword)); + expect(result[0]).toMatchObject({kind: 'same-word', field: 'headword'}); + }); + + it('matches a typed token against the decorated headword, not against a bare citation form', () => { + const byLexeme = makeEntry({lexemeForm: {seh: 'aji'}, morphType: MorphTypeKind.Suffix}); + // citation forms are never token-decorated, so its headword stays 'aji' + const byCitation = makeEntry({citationForm: {seh: 'aji'}, morphType: MorphTypeKind.Suffix}); + const result = classifyQueryResults([byLexeme, byCitation], vernQueries('-aji'), ws(decoratedHeadword)); + const kindById = new Map(result.map(m => [m.entry.id, m.kind])); + expect(kindById.get(byLexeme.id)).toBe('same-word'); + // the token can't match the bare citation as the same word; it only lands as a loose similar hit + expect(kindById.get(byCitation.id)).toBe('similar-word'); + }); + + it('matches the bare lexeme of a suffix as a lexeme hit, not a headword hit', () => { + // typing 'aji' (no token) against suffix entry '-aji': the lexeme matched, the headword didn't + const suffixEntry = makeEntry({lexemeForm: {seh: 'aji'}, morphType: MorphTypeKind.Suffix}); + const result = classifyQueryResults([suffixEntry], vernQueries('aji'), ws(decoratedHeadword)); + expect(result[0]).toMatchObject({kind: 'same-word', field: 'lexeme'}); + }); + + it('treats an exact-diacritic match as same-word and an accent-only difference as similar-word', () => { + const plain = makeEntry({lexemeForm: {seh: 'cafe'}}); + const accented = makeEntry({lexemeForm: {seh: 'café'}}); + // typed 'cafe': 'cafe' is the same word; 'café' matches only once accents are ignored -> similar + const typedPlain = new Map( + classifyQueryResults([accented, plain], vernQueries('cafe'), ws()).map(m => [m.entry.id, m.kind])); + expect(typedPlain.get(plain.id)).toBe('same-word'); + expect(typedPlain.get(accented.id)).toBe('similar-word'); + // typed 'café': now the accented entry is the exact match; the plain one is only similar + const typedAccented = classifyQueryResults([plain, accented], vernQueries('café'), ws()); + expect(typedAccented.find(m => m.entry.id === accented.id)).toMatchObject({kind: 'same-word', field: 'headword'}); + expect(typedAccented.find(m => m.entry.id === plain.id)?.kind).toBe('similar-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 = classifyQueryResults([viaLexemeOnly, viaCitation, lexemeIsHeadword], vernQueries('fuz'), ws()); + 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 = classifyQueryResults([entry], vernQueries('fuz', 'fuza'), ws()); + expect(result[0]).toEqual({entry, kind: 'same-word', field: 'headword'}); + }); + + it('classifies gloss overlap as same-meaning', () => { + const entry = withGloss('cabana', 'house'); + expect(classifyQueryResults([entry], queries, ws())[0].kind).toBe('same-meaning'); + }); + + it('classifies partial gloss containment in either direction as same-meaning', () => { + const glossContainsQuery = withGloss('cabana', 'houseboat'); + const queryContainsGloss = withGloss('cabana', 'use'); + const result = classifyQueryResults([glossContainsQuery, queryContainsGloss], queries, ws()); + 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(classifyQueryResults([entry], queries, ws())).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(classifyQueryResults([entry], vernQueries('nyumba'), ws())).toEqual([]); + }); + + 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 = classifyQueryResults([meaning, similarA, similarB, exact], queries, ws()); + expect(result.map(m => m.entry.id)).toEqual([exact.id, similarA.id, similarB.id, meaning.id]); + }); + + 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 = classifyQueryResults([entry], {vernacular: [], analysis: ['house']}, ws()); + 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 new file mode 100644 index 0000000000..a042397e76 --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/duplicate-check.ts @@ -0,0 +1,209 @@ +import type {IEntry, ISense} from '$lib/dotnet-types'; + +export const MIN_QUERY_LENGTH = 2; + +/** + * 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'; + +/** + * Which field an exact match hit: 'headword' when the value matched the displayed headword + * (citation form, or a morph-token-decorated lexeme), 'lexeme' when it matched only the bare + * lexeme form while the headword differs. 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 { + /** Diacritic-stripped, case-folded form sent to the backend search and used for the fuzzy + * (accent-insensitive) similar-word match. The backend only matches accent-insensitively when + * the query has no diacritics (MiniLcm StringExtensions.ContainsDiacriticMatch). */ + text: string; + /** Case-folded form with diacritics kept, used to decide the exact same-word match. */ + exact: 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: VernacularQuery[]; + /** Texts the user typed into gloss fields */ + analysis: string[]; +} + +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, +}; + +/** + * Case-folds invariantly (never by the host locale) and, unless asked to keep them, strips + * diacritics. The two modes back the two comparison tiers: keep diacritics to decide an exact + * same-word match, drop them for the accent-insensitive similar-word match. Collation-level + * equivalences (ß≈ss, ligatures, ICU-ignorable characters like soft hyphens) are not replicated. + */ +export function normalizeForCompare(value: string | undefined, keepDiacritics = false): string { + const decomposed = value?.normalize('NFD').toLowerCase().trim() ?? ''; + if (keepDiacritics) return decomposed; + return decomposed.replace(/\p{Mn}/gu, ''); +} + +export function getDuplicateEntryQueries( + entry: Pick, + sense: Pick | undefined, + vernacularWsIds: string[], + analysisWsIds: string[], +): DuplicateQueries { + const vernacular: VernacularQuery[] = []; + for (const wsId of vernacularWsIds) { + // The same text typed in two writing systems stays two queries — each is searched sorted by + // its own WS so that WS's headword matches rank first. Within a WS, lexeme/citation forms that + // fold to the same form (identical, or differing only by case) collapse; accent variants are + // kept, so each keeps its own exact same-word match. + const seen = new Set(); + for (const value of [entry.lexemeForm?.[wsId], entry.citationForm?.[wsId]]) { + const text = normalizeForCompare(value); + const exact = normalizeForCompare(value, true); + if (text.length < MIN_QUERY_LENGTH || seen.has(exact)) continue; + seen.add(exact); + vernacular.push({text, exact, wsId}); + } + } + + const analysis: string[] = []; + for (const value of analysisWsIds.map(wsId => sense?.gloss?.[wsId])) { + const normalized = normalizeForCompare(value); + if (normalized.length < MIN_QUERY_LENGTH) continue; + analysis.push(normalized); + } + return {vernacular, analysis}; +} + +/** 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; + }); +} + +/** The slice of WritingSystemService the classifier reads. Structural, so the classifier stays a + * pure function unit-testable without a live project context. */ +export interface DuplicateWritingSystems { + vernacularNoAudio: readonly {wsId: string}[]; + analysisNoAudio: readonly {wsId: string}[]; + /** Displayed headword for a writing system: citation form, else the morph-token-decorated lexeme. */ + headword(entry: IEntry, wsId: string): string | undefined; +} + +/** + * Classifies each candidate against the queries, returning matches strongest-first. `candidates` + * must be in search-relevance order: that order is kept among equally-strong matches, and similar + * words are then ordered closest-in-length first. + */ +export function classifyQueryResults( + candidates: IEntry[], + queries: DuplicateQueries, + writingSystems: DuplicateWritingSystems, +): DuplicateMatch[] { + const vernacularWsIds = writingSystems.vernacularNoAudio.map(ws => ws.wsId); + const analysisWsIds = writingSystems.analysisNoAudio.map(ws => ws.wsId); + const vernQueries = queries.vernacular.map(({exact, text}) => ({ + exact: normalizeForCompare(exact, true), + fuzzy: normalizeForCompare(text), + })); + const analysisQueries = queries.analysis.map(text => normalizeForCompare(text)); + + function vernacularForms(pick: (wsId: string) => string | undefined): string[] { + return vernacularWsIds.map(pick).filter((form): form is string => !!form); + } + + function matchesExactly(forms: string[]): boolean { + return vernQueries.some(query => forms.some(form => normalizeForCompare(form, true) === query.exact)); + } + + function sameWordField(headwordForms: string[], lexemeForms: string[]): SameWordField | undefined { + if (matchesExactly(headwordForms)) return 'headword'; + // a bare-lexeme hit is only 'lexeme' when the headword differs; a stem (lexeme IS the + // headword) already matched as 'headword' above + if (matchesExactly(lexemeForms)) return 'lexeme'; + return undefined; + } + + function similarWordDelta(forms: string[]): number | undefined { + const normalized = forms.map(form => normalizeForCompare(form)); + let delta = Infinity; + for (const query of vernQueries) { + for (const form of normalized) { + if (isSimilarWord(form, query.fuzzy)) delta = Math.min(delta, Math.abs(form.length - query.fuzzy.length)); + } + } + return delta === Infinity ? undefined : delta; + } + + function isSameMeaning(entry: IEntry): boolean { + const glosses = (entry.senses ?? []) + .flatMap(sense => analysisWsIds.map(wsId => sense.gloss?.[wsId])) + .filter((gloss): gloss is string => !!gloss) + .map(gloss => normalizeForCompare(gloss)); + return analysisQueries.some(query => glosses.some(gloss => gloss.includes(query) || query.includes(gloss))); + } + + function classify(entry: IEntry): {kind: DuplicateMatchKind, field?: SameWordField, lengthDelta: number} | undefined { + const headwordForms = vernacularForms(wsId => writingSystems.headword(entry, wsId)); + const lexemeForms = vernacularForms(wsId => entry.lexemeForm?.[wsId]); + + const field = sameWordField(headwordForms, lexemeForms); + if (field) return {kind: 'same-word', field, lengthDelta: 0}; + + const lengthDelta = similarWordDelta([...headwordForms, ...lexemeForms]); + if (lengthDelta !== undefined) return {kind: 'similar-word', lengthDelta}; + + if (isSameMeaning(entry)) return {kind: 'same-meaning', lengthDelta: 0}; + return undefined; + } + + return candidates + .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 66d42de9d2..08ce3eb36d 100644 --- a/frontend/viewer/src/locales/en.po +++ b/frontend/viewer/src/locales/en.po @@ -82,6 +82,11 @@ msgstr "{num, plural, one {# new FieldWorks Classic commit} other {# new FieldWo msgid "{num, plural, one {# new FieldWorks Lite commit} other {# new FieldWorks Lite commits}}" msgstr "{num, plural, one {# new FieldWorks Lite commit} other {# new FieldWorks Lite commits}}" +#. Button revealing the rest of a truncated possible-duplicates list in the New Entry dialog; # is the number of hidden matches +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "{num, plural, one {Show # more...} other {Show # more...}}" +msgstr "{num, plural, one {Show # more...} other {Show # more...}}" + #. Description of Lite view #: src/project/browse/ViewPicker.svelte msgid "**Fewer fields** (e.g. hides *Complex form types*, *Literal meaning*)" @@ -102,6 +107,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 @@ -152,6 +171,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" @@ -161,6 +187,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" @@ -173,6 +206,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 @@ -181,6 +221,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 @@ -381,6 +428,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..." @@ -417,6 +478,7 @@ msgstr "clear" #: src/lib/components/ui/button/x-button.svelte #: src/lib/entry-editor/CommentDialog.svelte #: src/lib/entry-editor/CommentDialog.svelte +#: src/lib/entry-editor/DuplicateSummaryPill.svelte #: src/project/tasks/SubjectPopup.svelte msgid "Close" msgstr "Close" @@ -512,6 +574,20 @@ msgstr "Copied to clipboard" msgid "Copy version" msgstr "Copy version" +#. Relevant view: Classic +#. Lite view equivalent: "Could not check for similar words" +#. Status line in the New Entry dialog when the background duplicate search failed (e.g. connection lost); typing again retries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Could not check for similar entries" +msgstr "Could not check for similar entries" + +#. Relevant view: Lite +#. Classic view equivalent: "Could not check for similar entries" +#. Status line in the New Word dialog when the background duplicate search failed (e.g. connection lost); typing again retries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Could not check for similar words" +msgstr "Could not check for similar words" + #. Transient warning toast (short timeout) shown when "Sync to local" fails (offline, server unreachable, or not logged in). #. Both clauses matter: the failure reason is implicit; the reassurance that local data is intact is intentional. #: src/project/SyncDialog.svelte @@ -1135,6 +1211,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" @@ -1379,6 +1469,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 🇦🇹 🇹🇭 🇺🇸" @@ -1424,6 +1521,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}\"" + #: src/lib/media-manager/MediaManagerView.svelte msgid "Media file details" msgstr "Media file details" @@ -1626,6 +1730,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}" @@ -2004,6 +2115,28 @@ 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: 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" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Save" msgstr "Save" @@ -2103,6 +2236,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 @@ -2149,6 +2289,48 @@ msgstr "Show {0} more..." msgid "Sign in to add comments." msgstr "Sign in to add comments." +#. 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" @@ -2351,6 +2533,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" + #: src/lib/media-manager/MediaFileDetail.svelte msgid "This file is only available remotely." msgstr "This file is only available remotely." @@ -2388,6 +2577,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" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Thread" msgstr "Thread" diff --git a/frontend/viewer/src/locales/es.po b/frontend/viewer/src/locales/es.po index a21e5bb4c0..a59c587f7a 100644 --- a/frontend/viewer/src/locales/es.po +++ b/frontend/viewer/src/locales/es.po @@ -87,6 +87,11 @@ msgstr "{num, plural, one {# nuevo FieldWorks Classic commit} other {# nuevos Fi msgid "{num, plural, one {# new FieldWorks Lite commit} other {# new FieldWorks Lite commits}}" msgstr "{num, plural, one {# nuevo commit de FieldWorks Lite} other {# nuevos commits de FieldWorks Lite}}" +#. Button revealing the rest of a truncated possible-duplicates list in the New Entry dialog; # is the number of hidden matches +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "{num, plural, one {Show # more...} other {Show # more...}}" +msgstr "" + #. Description of Lite view #: src/project/browse/ViewPicker.svelte msgid "**Fewer fields** (e.g. hides *Complex form types*, *Literal meaning*)" @@ -107,6 +112,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 @@ -157,6 +176,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" @@ -166,6 +192,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" @@ -178,6 +211,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 @@ -186,6 +226,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 @@ -386,6 +433,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..." @@ -422,6 +483,7 @@ msgstr "borrar" #: src/lib/components/ui/button/x-button.svelte #: src/lib/entry-editor/CommentDialog.svelte #: src/lib/entry-editor/CommentDialog.svelte +#: src/lib/entry-editor/DuplicateSummaryPill.svelte #: src/project/tasks/SubjectPopup.svelte msgid "Close" msgstr "Cerrar" @@ -517,6 +579,20 @@ msgstr "Copiado al portapapeles" msgid "Copy version" msgstr "Copiar versión" +#. Relevant view: Classic +#. Lite view equivalent: "Could not check for similar words" +#. Status line in the New Entry dialog when the background duplicate search failed (e.g. connection lost); typing again retries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Could not check for similar entries" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Could not check for similar entries" +#. Status line in the New Word dialog when the background duplicate search failed (e.g. connection lost); typing again retries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Could not check for similar words" +msgstr "" + #. Transient warning toast (short timeout) shown when "Sync to local" fails (offline, server unreachable, or not logged in). #. Both clauses matter: the failure reason is implicit; the reassurance that local data is intact is intentional. #: src/project/SyncDialog.svelte @@ -1140,6 +1216,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" @@ -1384,6 +1474,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 🇦🇹 🇹🇭 🇺🇸" @@ -1429,6 +1526,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 "" + #: src/lib/media-manager/MediaManagerView.svelte msgid "Media file details" msgstr "" @@ -1631,6 +1735,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}" @@ -2009,6 +2120,28 @@ 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: 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 "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Save" msgstr "" @@ -2108,6 +2241,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 @@ -2154,6 +2294,48 @@ msgstr "Mostrar {0} más..." msgid "Sign in to add comments." msgstr "" +#. 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" @@ -2356,6 +2538,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 "" + #: src/lib/media-manager/MediaFileDetail.svelte msgid "This file is only available remotely." msgstr "" @@ -2393,6 +2582,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 "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Thread" msgstr "" diff --git a/frontend/viewer/src/locales/fr.po b/frontend/viewer/src/locales/fr.po index 49c34eb17f..e6a9daeca5 100644 --- a/frontend/viewer/src/locales/fr.po +++ b/frontend/viewer/src/locales/fr.po @@ -87,6 +87,11 @@ msgstr "{num, plural, one {# nouveau commit FieldWorks Classic} other {# nouveau msgid "{num, plural, one {# new FieldWorks Lite commit} other {# new FieldWorks Lite commits}}" msgstr "{num, plural, one {# nouveau commit FieldWorks Lite} other {# nouveaux commits FieldWorks Lite}}" +#. Button revealing the rest of a truncated possible-duplicates list in the New Entry dialog; # is the number of hidden matches +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "{num, plural, one {Show # more...} other {Show # more...}}" +msgstr "" + #. Description of Lite view #: src/project/browse/ViewPicker.svelte msgid "**Fewer fields** (e.g. hides *Complex form types*, *Literal meaning*)" @@ -107,6 +112,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 @@ -157,6 +176,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" @@ -166,6 +192,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" @@ -178,6 +211,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 @@ -186,6 +226,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 @@ -386,6 +433,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..." @@ -422,6 +483,7 @@ msgstr "effacer" #: src/lib/components/ui/button/x-button.svelte #: src/lib/entry-editor/CommentDialog.svelte #: src/lib/entry-editor/CommentDialog.svelte +#: src/lib/entry-editor/DuplicateSummaryPill.svelte #: src/project/tasks/SubjectPopup.svelte msgid "Close" msgstr "Fermer" @@ -517,6 +579,20 @@ msgstr "Copié dans le presse-papiers" msgid "Copy version" msgstr "Copier la version dans le presse-papiers" +#. Relevant view: Classic +#. Lite view equivalent: "Could not check for similar words" +#. Status line in the New Entry dialog when the background duplicate search failed (e.g. connection lost); typing again retries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Could not check for similar entries" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Could not check for similar entries" +#. Status line in the New Word dialog when the background duplicate search failed (e.g. connection lost); typing again retries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Could not check for similar words" +msgstr "" + #. Transient warning toast (short timeout) shown when "Sync to local" fails (offline, server unreachable, or not logged in). #. Both clauses matter: the failure reason is implicit; the reassurance that local data is intact is intentional. #: src/project/SyncDialog.svelte @@ -1140,6 +1216,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" @@ -1384,6 +1474,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 🇦🇹 🇹🇭 🇺🇸" @@ -1429,6 +1526,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 "" + #: src/lib/media-manager/MediaManagerView.svelte msgid "Media file details" msgstr "" @@ -1631,6 +1735,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}" @@ -2009,6 +2120,28 @@ 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: 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 "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Save" msgstr "" @@ -2108,6 +2241,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 @@ -2154,6 +2294,48 @@ msgstr "Afficher {0} plus..." msgid "Sign in to add comments." msgstr "" +#. 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" @@ -2356,6 +2538,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 "" + #: src/lib/media-manager/MediaFileDetail.svelte msgid "This file is only available remotely." msgstr "" @@ -2393,6 +2582,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 "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Thread" msgstr "" diff --git a/frontend/viewer/src/locales/id.po b/frontend/viewer/src/locales/id.po index 2c503d0f62..e1fde7ef96 100644 --- a/frontend/viewer/src/locales/id.po +++ b/frontend/viewer/src/locales/id.po @@ -87,6 +87,11 @@ msgstr "{num, plural, one {# new FieldWorks Classic commit} other {# new FieldWo msgid "{num, plural, one {# new FieldWorks Lite commit} other {# new FieldWorks Lite commits}}" msgstr "{num, plural, one {# new FieldWorks Lite commit} other {# new FieldWorks Lite commits}}" +#. Button revealing the rest of a truncated possible-duplicates list in the New Entry dialog; # is the number of hidden matches +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "{num, plural, one {Show # more...} other {Show # more...}}" +msgstr "" + #. Description of Lite view #: src/project/browse/ViewPicker.svelte msgid "**Fewer fields** (e.g. hides *Complex form types*, *Literal meaning*)" @@ -107,6 +112,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 @@ -157,6 +176,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" @@ -166,6 +192,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" @@ -178,6 +211,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 @@ -186,6 +226,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 @@ -386,6 +433,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..." @@ -422,6 +483,7 @@ msgstr "jelas" #: src/lib/components/ui/button/x-button.svelte #: src/lib/entry-editor/CommentDialog.svelte #: src/lib/entry-editor/CommentDialog.svelte +#: src/lib/entry-editor/DuplicateSummaryPill.svelte #: src/project/tasks/SubjectPopup.svelte msgid "Close" msgstr "Tutup" @@ -517,6 +579,20 @@ msgstr "Disalin ke papan klip" msgid "Copy version" msgstr "Versi salin" +#. Relevant view: Classic +#. Lite view equivalent: "Could not check for similar words" +#. Status line in the New Entry dialog when the background duplicate search failed (e.g. connection lost); typing again retries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Could not check for similar entries" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Could not check for similar entries" +#. Status line in the New Word dialog when the background duplicate search failed (e.g. connection lost); typing again retries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Could not check for similar words" +msgstr "" + #. Transient warning toast (short timeout) shown when "Sync to local" fails (offline, server unreachable, or not logged in). #. Both clauses matter: the failure reason is implicit; the reassurance that local data is intact is intentional. #: src/project/SyncDialog.svelte @@ -1140,6 +1216,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" @@ -1384,6 +1474,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 🇦🇹 🇹🇭 🇺🇸" @@ -1429,6 +1526,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 "" + #: src/lib/media-manager/MediaManagerView.svelte msgid "Media file details" msgstr "" @@ -1631,6 +1735,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}" @@ -2009,6 +2120,28 @@ 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: 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 "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Save" msgstr "" @@ -2108,6 +2241,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 @@ -2154,6 +2294,48 @@ msgstr "Tampilkan {0} selengkapnya..." msgid "Sign in to add comments." msgstr "" +#. 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" @@ -2356,6 +2538,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 "" + #: src/lib/media-manager/MediaFileDetail.svelte msgid "This file is only available remotely." msgstr "" @@ -2393,6 +2582,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 "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Thread" msgstr "" diff --git a/frontend/viewer/src/locales/ko.po b/frontend/viewer/src/locales/ko.po index d2561a8ee1..444061c33f 100644 --- a/frontend/viewer/src/locales/ko.po +++ b/frontend/viewer/src/locales/ko.po @@ -87,6 +87,11 @@ msgstr "{num, plural, other {# 새 FieldWorks Classic 커밋}}" msgid "{num, plural, one {# new FieldWorks Lite commit} other {# new FieldWorks Lite commits}}" msgstr "{num, plural, other {# 새 FieldWorks Lite 커밋}}" +#. Button revealing the rest of a truncated possible-duplicates list in the New Entry dialog; # is the number of hidden matches +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "{num, plural, one {Show # more...} other {Show # more...}}" +msgstr "" + #. Description of Lite view #: src/project/browse/ViewPicker.svelte msgid "**Fewer fields** (e.g. hides *Complex form types*, *Literal meaning*)" @@ -107,6 +112,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 @@ -157,6 +176,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" @@ -166,6 +192,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" @@ -178,6 +211,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 @@ -186,6 +226,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 @@ -386,6 +433,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..." @@ -422,6 +483,7 @@ msgstr "clear" #: src/lib/components/ui/button/x-button.svelte #: src/lib/entry-editor/CommentDialog.svelte #: src/lib/entry-editor/CommentDialog.svelte +#: src/lib/entry-editor/DuplicateSummaryPill.svelte #: src/project/tasks/SubjectPopup.svelte msgid "Close" msgstr "닫기" @@ -517,6 +579,20 @@ msgstr "클립보드에 복사" msgid "Copy version" msgstr "복사 버전" +#. Relevant view: Classic +#. Lite view equivalent: "Could not check for similar words" +#. Status line in the New Entry dialog when the background duplicate search failed (e.g. connection lost); typing again retries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Could not check for similar entries" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Could not check for similar entries" +#. Status line in the New Word dialog when the background duplicate search failed (e.g. connection lost); typing again retries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Could not check for similar words" +msgstr "" + #. Transient warning toast (short timeout) shown when "Sync to local" fails (offline, server unreachable, or not logged in). #. Both clauses matter: the failure reason is implicit; the reassurance that local data is intact is intentional. #: src/project/SyncDialog.svelte @@ -1140,6 +1216,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" @@ -1384,6 +1474,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 🇦🇹 🇹🇭 🇺🇸" @@ -1429,6 +1526,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 "" + #: src/lib/media-manager/MediaManagerView.svelte msgid "Media file details" msgstr "" @@ -1631,6 +1735,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}" @@ -2009,6 +2120,28 @@ 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: 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 "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Save" msgstr "" @@ -2108,6 +2241,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 @@ -2154,6 +2294,48 @@ msgstr "보기 {0} 더보기..." msgid "Sign in to add comments." msgstr "" +#. 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" @@ -2356,6 +2538,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 "" + #: src/lib/media-manager/MediaFileDetail.svelte msgid "This file is only available remotely." msgstr "" @@ -2393,6 +2582,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 "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Thread" msgstr "" diff --git a/frontend/viewer/src/locales/ms.po b/frontend/viewer/src/locales/ms.po index cbf8010b54..ae40869ca4 100644 --- a/frontend/viewer/src/locales/ms.po +++ b/frontend/viewer/src/locales/ms.po @@ -87,6 +87,11 @@ msgstr "{num, plural, other {# komit FieldWorks Classic baharu}}" msgid "{num, plural, one {# new FieldWorks Lite commit} other {# new FieldWorks Lite commits}}" msgstr "{num, plural, other {# komit FieldWorks Lite baharu}}" +#. Button revealing the rest of a truncated possible-duplicates list in the New Entry dialog; # is the number of hidden matches +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "{num, plural, one {Show # more...} other {Show # more...}}" +msgstr "" + #. Description of Lite view #: src/project/browse/ViewPicker.svelte msgid "**Fewer fields** (e.g. hides *Complex form types*, *Literal meaning*)" @@ -107,6 +112,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 @@ -157,6 +176,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" @@ -166,6 +192,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" @@ -178,6 +211,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 @@ -186,6 +226,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 @@ -386,6 +433,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..." @@ -422,6 +483,7 @@ msgstr "padam" #: src/lib/components/ui/button/x-button.svelte #: src/lib/entry-editor/CommentDialog.svelte #: src/lib/entry-editor/CommentDialog.svelte +#: src/lib/entry-editor/DuplicateSummaryPill.svelte #: src/project/tasks/SubjectPopup.svelte msgid "Close" msgstr "Tutup" @@ -517,6 +579,20 @@ msgstr "Disalin ke papan keratan" msgid "Copy version" msgstr "Salin versi" +#. Relevant view: Classic +#. Lite view equivalent: "Could not check for similar words" +#. Status line in the New Entry dialog when the background duplicate search failed (e.g. connection lost); typing again retries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Could not check for similar entries" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Could not check for similar entries" +#. Status line in the New Word dialog when the background duplicate search failed (e.g. connection lost); typing again retries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Could not check for similar words" +msgstr "" + #. Transient warning toast (short timeout) shown when "Sync to local" fails (offline, server unreachable, or not logged in). #. Both clauses matter: the failure reason is implicit; the reassurance that local data is intact is intentional. #: src/project/SyncDialog.svelte @@ -1140,6 +1216,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" @@ -1384,6 +1474,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 🇦🇹 🇹🇭 🇺🇸" @@ -1429,6 +1526,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 "" + #: src/lib/media-manager/MediaManagerView.svelte msgid "Media file details" msgstr "" @@ -1631,6 +1735,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}" @@ -2009,6 +2120,28 @@ 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: 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 "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Save" msgstr "" @@ -2108,6 +2241,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 @@ -2154,6 +2294,48 @@ msgstr "Tunjukkan {0} lagi..." msgid "Sign in to add comments." msgstr "" +#. 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" @@ -2356,6 +2538,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 "" + #: src/lib/media-manager/MediaFileDetail.svelte msgid "This file is only available remotely." msgstr "" @@ -2393,6 +2582,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 "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Thread" msgstr "" diff --git a/frontend/viewer/src/locales/sw.po b/frontend/viewer/src/locales/sw.po index 196528f983..14dcc73638 100644 --- a/frontend/viewer/src/locales/sw.po +++ b/frontend/viewer/src/locales/sw.po @@ -87,6 +87,11 @@ msgstr "{num, plural, one {# kumbuka mpya ya FieldWorks Classic} other {# kumbuk msgid "{num, plural, one {# new FieldWorks Lite commit} other {# new FieldWorks Lite commits}}" msgstr "{num, plural, one {# kumbuka mpya ya FieldWorks Lite} other {# kumbukas mpya za FieldWorks Lite}}" +#. Button revealing the rest of a truncated possible-duplicates list in the New Entry dialog; # is the number of hidden matches +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "{num, plural, one {Show # more...} other {Show # more...}}" +msgstr "" + #. Description of Lite view #: src/project/browse/ViewPicker.svelte msgid "**Fewer fields** (e.g. hides *Complex form types*, *Literal meaning*)" @@ -107,6 +112,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 @@ -157,6 +176,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" @@ -166,6 +192,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" @@ -178,6 +211,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 @@ -186,6 +226,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 @@ -386,6 +433,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..." @@ -422,6 +483,7 @@ msgstr "futa" #: src/lib/components/ui/button/x-button.svelte #: src/lib/entry-editor/CommentDialog.svelte #: src/lib/entry-editor/CommentDialog.svelte +#: src/lib/entry-editor/DuplicateSummaryPill.svelte #: src/project/tasks/SubjectPopup.svelte msgid "Close" msgstr "Funga" @@ -517,6 +579,20 @@ msgstr "Imenakiliwa kwenye ubao wa kunakili" msgid "Copy version" msgstr "Nakili toleo" +#. Relevant view: Classic +#. Lite view equivalent: "Could not check for similar words" +#. Status line in the New Entry dialog when the background duplicate search failed (e.g. connection lost); typing again retries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Could not check for similar entries" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Could not check for similar entries" +#. Status line in the New Word dialog when the background duplicate search failed (e.g. connection lost); typing again retries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Could not check for similar words" +msgstr "" + #. Transient warning toast (short timeout) shown when "Sync to local" fails (offline, server unreachable, or not logged in). #. Both clauses matter: the failure reason is implicit; the reassurance that local data is intact is intentional. #: src/project/SyncDialog.svelte @@ -1140,6 +1216,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" @@ -1384,6 +1474,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 🇦🇹 🇹🇭 🇺🇸" @@ -1429,6 +1526,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 "" + #: src/lib/media-manager/MediaManagerView.svelte msgid "Media file details" msgstr "" @@ -1631,6 +1735,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}" @@ -2009,6 +2120,28 @@ 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: 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 "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Save" msgstr "" @@ -2108,6 +2241,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 @@ -2154,6 +2294,48 @@ msgstr "Onyesha {0} zaidi..." msgid "Sign in to add comments." msgstr "" +#. 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" @@ -2356,6 +2538,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 "" + #: src/lib/media-manager/MediaFileDetail.svelte msgid "This file is only available remotely." msgstr "" @@ -2393,6 +2582,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 "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Thread" msgstr "" diff --git a/frontend/viewer/src/locales/vi.po b/frontend/viewer/src/locales/vi.po index 385767a47f..e7b22fc2fb 100644 --- a/frontend/viewer/src/locales/vi.po +++ b/frontend/viewer/src/locales/vi.po @@ -87,6 +87,11 @@ msgstr "{num, plural, other {# commit FieldWorks Classic mới}}" msgid "{num, plural, one {# new FieldWorks Lite commit} other {# new FieldWorks Lite commits}}" msgstr "{num, plural, other {# commit FieldWorks Lite mới}}" +#. Button revealing the rest of a truncated possible-duplicates list in the New Entry dialog; # is the number of hidden matches +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "{num, plural, one {Show # more...} other {Show # more...}}" +msgstr "" + #. Description of Lite view #: src/project/browse/ViewPicker.svelte msgid "**Fewer fields** (e.g. hides *Complex form types*, *Literal meaning*)" @@ -107,6 +112,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 @@ -157,6 +176,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" @@ -166,6 +192,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" @@ -178,6 +211,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 @@ -186,6 +226,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 @@ -386,6 +433,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..." @@ -422,6 +483,7 @@ msgstr "xóa" #: src/lib/components/ui/button/x-button.svelte #: src/lib/entry-editor/CommentDialog.svelte #: src/lib/entry-editor/CommentDialog.svelte +#: src/lib/entry-editor/DuplicateSummaryPill.svelte #: src/project/tasks/SubjectPopup.svelte msgid "Close" msgstr "Đóng" @@ -517,6 +579,20 @@ msgstr "Đã sao chép vào khay nhớ tạm" msgid "Copy version" msgstr "Sao chép phiên bản" +#. Relevant view: Classic +#. Lite view equivalent: "Could not check for similar words" +#. Status line in the New Entry dialog when the background duplicate search failed (e.g. connection lost); typing again retries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Could not check for similar entries" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "Could not check for similar entries" +#. Status line in the New Word dialog when the background duplicate search failed (e.g. connection lost); typing again retries +#: src/lib/entry-editor/DuplicateCheck.svelte +msgid "Could not check for similar words" +msgstr "" + #. Transient warning toast (short timeout) shown when "Sync to local" fails (offline, server unreachable, or not logged in). #. Both clauses matter: the failure reason is implicit; the reassurance that local data is intact is intentional. #: src/project/SyncDialog.svelte @@ -1140,6 +1216,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" @@ -1384,6 +1474,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 🇦🇹 🇹🇭 🇺🇸" @@ -1429,6 +1526,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 "" + #: src/lib/media-manager/MediaManagerView.svelte msgid "Media file details" msgstr "" @@ -1631,6 +1735,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}" @@ -2009,6 +2120,28 @@ 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: 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 "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Save" msgstr "" @@ -2108,6 +2241,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 @@ -2154,6 +2294,48 @@ msgstr "Hiển thị thêm {0}..." msgid "Sign in to add comments." msgstr "" +#. 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" @@ -2356,6 +2538,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 "" + #: src/lib/media-manager/MediaFileDetail.svelte msgid "This file is only available remotely." msgstr "" @@ -2393,6 +2582,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 "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Thread" msgstr "" diff --git a/frontend/viewer/tests/ui/new-entry-duplicates.test.ts b/frontend/viewer/tests/ui/new-entry-duplicates.test.ts new file mode 100644 index 0000000000..0a8ea28b80 --- /dev/null +++ b/frontend/viewer/tests/ui/new-entry-duplicates.test.ts @@ -0,0 +1,209 @@ +import {expect, test, type Locator, type Page} from '@playwright/test'; +import {DemoProjectPage} from './demo-project.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 match row expands in place + * to offer "Go to entry" / "Add sense". + */ + +// 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; + +/** 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.locator('li > button[aria-expanded]:visible'); +} + +test.describe('New entry possible duplicates', () => { + test('typing an existing word shows duplicates and can navigate to one', async ({page}) => { + const projectPage = new DemoProjectPage(page); + await projectPage.goto(); + + const dialog = await openNewEntryDialog(page); + await lexemeInput(dialog).fill(existingLexeme); + + // exact headword match => attention strip + auto-expanded list + 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 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 projectPage.entryView.getLexemeInput(); + await expect(openedLexeme).toHaveValue(existingLexeme); + }); + + test('brand-new word shows the new-word indicator', async ({page}) => { + const projectPage = new DemoProjectPage(page); + await projectPage.goto(); + + const dialog = await openNewEntryDialog(page); + await lexemeInput(dialog).fill('zyzzyvazz'); + await expect(dialog.getByText(newWordIndicator)).toBeVisible(); + await expect(stripSummary(dialog)).toBeHidden(); + }); + + test('typed meaning can be added to an existing entry instead', async ({page}) => { + const projectPage = new DemoProjectPage(page); + await projectPage.goto(); + + const entryCountBefore = await projectPage.api.countEntries(); + 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; 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=/); + const entryId = new URL(page.url()).searchParams.get('entryId'); + expect(entryId).toBeTruthy(); + await expect(async () => { + expect(await projectPage.api.entryHasGlossValue(entryId!, newGloss)).toBe(true); + }).toPass({timeout: 5000}); + // the sense landed on the existing entry INSTEAD of a new one being created + expect(await projectPage.api.countEntries()).toBe(entryCountBefore); + }); + + test('Enter inside the duplicate strip expands the row without creating the entry', async ({page}) => { + const projectPage = new DemoProjectPage(page); + await projectPage.goto(); + + const entryCountBefore = await projectPage.api.countEntries(); + const dialog = await openNewEntryDialog(page); + await lexemeInput(dialog).fill(existingLexeme); + + const duplicateRow = duplicateRows(dialog).filter({hasText: existingLexeme}).first(); + await duplicateRow.focus(); + await page.keyboard.press('Enter'); + + // Enter activated the focused row, and was NOT also swallowed by the dialog's + // submit-on-Enter handler — which would have created the very duplicate being warned about + await expect(duplicateRow).toHaveAttribute('aria-expanded', 'true'); + await expect(dialog).toBeVisible(); + expect(await projectPage.api.countEntries()).toBe(entryCountBefore); + }); + + test('partial headword match shows a collapsed strip with a similar-word badge', async ({page}) => { + const projectPage = new DemoProjectPage(page); + await projectPage.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 = stripSummary(dialog); + 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 projectPage = new DemoProjectPage(page); + await projectPage.goto(); + + const dialog = await openNewEntryDialog(page); + await lexemeInput(dialog).fill('ba'); + + const summary = stripSummary(dialog); + await expect(summary).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 + 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 projectPage = new DemoProjectPage(page); + await projectPage.goto(); + + const dialog = await openNewEntryDialog(page); + await lexemeInput(dialog).fill('zyzzyvazz'); + await glossInput(dialog).fill(existingGloss); + + 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 projectPage = new DemoProjectPage(page); + await projectPage.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(); + }); + + test('the jump pill can be dismissed and stays dismissed', async ({page}) => { + await page.setViewportSize({width: 1024, height: 560}); + const projectPage = new DemoProjectPage(page); + await projectPage.goto(); + + const dialog = await openNewEntryDialog(page); + await lexemeInput(dialog).fill(existingLexeme); + + const pill = dialog.getByRole('button', {name: /^this (entry|word) may already exist$/i}); + await expect(pill).toBeVisible(); + // the pill's Close is its sibling; dialogs have their own Close button, so scope to the pill's parent + await pill.locator('..').getByRole('button', {name: /close/i}).click(); + await expect(pill).toBeHidden(); + + // still dismissed while matches keep changing out of view + await lexemeInput(dialog).fill(existingLexeme.slice(0, -1)); + await lexemeInput(dialog).fill(existingLexeme); + await expect(dialog.getByText(duplicatesSummary).first()).toBeAttached(); + await expect(pill).toBeHidden(); + }); +});