diff --git a/frontend/viewer/src/lib/activity/ActivityItemChangePreview.svelte b/frontend/viewer/src/lib/activity/ActivityItemChangePreview.svelte index 60f1cb2e27..86e5ea6386 100644 --- a/frontend/viewer/src/lib/activity/ActivityItemChangePreview.svelte +++ b/frontend/viewer/src/lib/activity/ActivityItemChangePreview.svelte @@ -80,7 +80,7 @@ e.preventDefault()}> {#snippet child({props})} - + {$t`Go to ${pt($t`Entry`, $t`Word`, viewService.currentView)}`} {/snippet} 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/EntryOrSenseItemList.svelte b/frontend/viewer/src/lib/entry-editor/EntryOrSenseItemList.svelte index f23032f8a1..e91bcb7ce9 100644 --- a/frontend/viewer/src/lib/entry-editor/EntryOrSenseItemList.svelte +++ b/frontend/viewer/src/lib/entry-editor/EntryOrSenseItemList.svelte @@ -30,7 +30,7 @@ {#snippet child({props})} - + {$t`Go to ${pt($t`Entry`, $t`Word`, viewService.currentView)}`} {/snippet} diff --git a/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte b/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte index 32f6b7515a..3af39762ab 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 './duplicate-check/DuplicateCheckSection.svelte'; import EntryEditorPrimitive from './object-editors/EntryEditorPrimitive.svelte'; import ObjectHeader from './object-editors/ObjectHeader.svelte'; import SenseEditorPrimitive from './object-editors/SenseEditorPrimitive.svelte'; @@ -59,21 +60,26 @@ async function createEntry(e: Event) { e.preventDefault(); e.stopPropagation(); + // we might already be creating something + if (loading) 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 +184,16 @@ {/snippet} - + + {pt($t`New Entry`, $t`New Word`, viewService.currentView)} -
+ +
+ open = false} />
{#if errors.length}
@@ -221,7 +234,7 @@ {/if} - diff --git a/frontend/viewer/src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte b/frontend/viewer/src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte new file mode 100644 index 0000000000..5960b2984f --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte @@ -0,0 +1,316 @@ + + + + + +
+ {#if !matches?.length} + {#if (duplicatesResource.loading && hasQueries) || matches || duplicatesResource.error} +
+ {#if duplicatesResource.loading && !matches} + + {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} + + {#if duplicatesResource.loading} + + {:else} + + {/if} + {pt($t`No similar entries found`, $t`No similar words found`, viewService.currentView)} + {/if} +
+ {/if} + {:else} +
+ + + {#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} +
  • + {/each} + +
  • displayedMatches.length || 'hidden')}> + +
  • +
+
+
+
+ {/if} +
diff --git a/frontend/viewer/src/lib/entry-editor/duplicate-check/DuplicateCheckSection.svelte b/frontend/viewer/src/lib/entry-editor/duplicate-check/DuplicateCheckSection.svelte new file mode 100644 index 0000000000..9a509c9892 --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/duplicate-check/DuplicateCheckSection.svelte @@ -0,0 +1,57 @@ + + + +
+ +
+{#if duplicateSummary && !duplicateWidgetVisible && !pillDismissed} + +{/if} diff --git a/frontend/viewer/src/lib/entry-editor/duplicate-check/DuplicateSummaryPill.svelte b/frontend/viewer/src/lib/entry-editor/duplicate-check/DuplicateSummaryPill.svelte new file mode 100644 index 0000000000..68b3929530 --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/duplicate-check/DuplicateSummaryPill.svelte @@ -0,0 +1,50 @@ + + +
+
+ + +
+
diff --git a/frontend/viewer/src/lib/entry-editor/duplicate-check/duplicate-check.test.ts b/frontend/viewer/src/lib/entry-editor/duplicate-check/duplicate-check.test.ts new file mode 100644 index 0000000000..b1cbf8f6bc --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/duplicate-check/duplicate-check.test.ts @@ -0,0 +1,281 @@ +import {describe, expect, it, vi} from 'vitest'; +import type {IEntry, IWritingSystem} from '$lib/dotnet-types'; +import {MorphTypeKind} from '$lib/dotnet-types'; +import { + classifyDuplicateCheckResults, + getDuplicateCheckQueries, + isSimilarWord, + mergeSearchResults, + normalizeForExactCompare, + normalizeForLooseCompare, + trapEnter, + type DuplicateCheckQueries, + type DuplicateCheckWritingSystems +} from './duplicate-check'; + +// The WritingSystemService slice classifyQueryResults reads; defaults to the undecorated headword. +const writingSystems: DuplicateCheckWritingSystems = Object.freeze({ + vernacularNoAudio: ['seh', 'por'].map(wsId => ({wsId}) as IWritingSystem), + analysisNoAudio: ['en', 'fr'].map(wsId => ({wsId}) as IWritingSystem), + headword(entry, wsId): string { + if (!wsId) throw new Error('these tests always pass a writing system'); + const citation = entry.citationForm?.[wsId]?.trim(); + if (citation) return citation; + const lexeme = entry.lexemeForm?.[wsId]?.trim(); + if (!lexeme) return ''; + return entry.morphType === MorphTypeKind.Suffix ? `-${lexeme}` : lexeme; + }, +}); + +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 the way getDuplicateEntryQueries would: `exact` keeps diacritics, +// `bare` is the stripped form sent to the backend. classifyQueryResults consumes these as-is, so the +// helper normalizes here just like production does. +function vernQueries(...texts: string[]): DuplicateCheckQueries { + return { + vernacular: texts.map(text => ({ + bare: normalizeForLooseCompare(text), + exact: normalizeForExactCompare(text), + wsId: 'seh', + })), + analysis: [], + }; +} + +function analysisQuery(gloss: string): DuplicateCheckQueries { + return {vernacular: [], analysis: [normalizeForLooseCompare(gloss)]}; +} + +describe('normalizeForLooseCompare', () => { + it('case-folds and strips diacritics', () => { + expect(normalizeForLooseCompare('Ñumbá ')).toBe('numba'); + expect(normalizeForLooseCompare('CAFÉ')).toBe('cafe'); + }); + + it('treats a missing value as empty', () => { + expect(normalizeForLooseCompare(undefined)).toBe(''); + }); +}); + +describe('normalizeForExactCompare', () => { + it('only decomposes (preserves case and diacritics)', () => { + expect(normalizeForExactCompare('Café'.normalize('NFC'))) + .toBe('Café'.normalize('NFD')); + }); + + it('treats a missing value as empty', () => { + expect(normalizeForExactCompare(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('getDuplicateCheckQueries', () => { + it('collects and normalizes query values', () => { + const queries = getDuplicateCheckQueries( + {lexemeForm: {seh: 'Ñyumba', por: 'casa'}, citationForm: {}}, + {gloss: {en: 'house', fr: 'Allé'}}, + writingSystems, + ); + expect(queries.vernacular).toEqual([ + {bare: 'nyumba', exact: 'Ñyumba'.normalize('NFD'), wsId: 'seh'}, + {bare: 'casa', exact: 'casa', wsId: 'por'}, + ]); + expect(queries.analysis).toEqual(['house', 'alle']); + }); + + it('skips blank and whitespace-only values', () => { + const queries = getDuplicateCheckQueries( + {lexemeForm: {seh: ' '}, citationForm: {}}, + {gloss: {en: ''}}, + writingSystems, + ); + expect(queries.vernacular).toEqual([]); + expect(queries.analysis).toEqual([]); + }); + + it('keeps short values (no minimum length)', () => { + const queries = getDuplicateCheckQueries( + {lexemeForm: {seh: 'a'}, citationForm: {}}, + undefined, + writingSystems, + ); + expect(queries.vernacular).toEqual([{bare: 'a', exact: 'a', wsId: 'seh'}]); + }); + + it('only keeps non audio WS\'s', () => { + const queries = getDuplicateCheckQueries( + {lexemeForm: {'seh-Zxxx-x-audio': 'clip.wav', seh: 'nyumba'}, citationForm: {}}, + undefined, + writingSystems, + ); + expect(queries.vernacular).toEqual([{bare: '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('isSimilarWord', () => { + it('matches containment in either direction', () => { + expect(isSimilarWord('abc', 'abcde')).toBe(true); + expect(isSimilarWord('abcde', 'abc')).toBe(true); + expect(isSimilarWord('abc', 'xabcx')).toBe(true); + expect(isSimilarWord('abc', 'abd')).toBe(false); + }); + + it('allows the longer word at most 3 extra characters', () => { + expect(isSimilarWord('abc', 'abcdef')).toBe(true); + expect(isSimilarWord('abc', 'abcdefg')).toBe(false); + }); + + it('allows short words even fewer extra characters: their own length', () => { + expect(isSimilarWord('a', 'an')).toBe(true); + expect(isSimilarWord('a', 'and')).toBe(false); + expect(isSimilarWord('uz', 'uzem')).toBe(true); + expect(isSimilarWord('uz', 'uzemb')).toBe(false); + }); + + it('never matches an empty value', () => { + expect(isSimilarWord('', 'abc')).toBe(false); + expect(isSimilarWord('abc', '')).toBe(false); + }); +}); + +describe('classifyQueryResults', () => { + + it('classifies exact headword matches as same-word, even via lexeme form or in other ws', () => { + const byLexeme = makeEntry({lexemeForm: {seh: 'nyumba'}}); + const byCitation = makeEntry({citationForm: {por: 'nyumba'}}); + const result = classifyDuplicateCheckResults([byLexeme, byCitation], vernQueries('nyumba'), writingSystems); + expect(result.map(m => m.kind)).toEqual(['same-word', 'same-word']); + }); + + it('classifies partial headword overlap as similar-word', () => { + const superstring = makeEntry({lexemeForm: {seh: 'nyumbazi'}}); + const result = classifyDuplicateCheckResults([superstring], vernQueries('nyumba'), writingSystems); + expect(result.map(m => m.kind)).toEqual(['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: 'naliman'}}); + expect(classifyDuplicateCheckResults([buried], vernQueries('liman'), writingSystems)[0]?.kind).toBe('similar-word'); + }); + + 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 = classifyDuplicateCheckResults([suffixEntry], vernQueries('-aji'), writingSystems); + expect(result[0]).toMatchObject({kind: 'same-word', field: 'headword'}); + }); + + it('matches a typed token against the decorated headword', () => { + // typing '-aji' (with token) against suffix entry '-aji' + const byLexeme = makeEntry({lexemeForm: {seh: 'aji'}, morphType: MorphTypeKind.Suffix}); + const byCitation = makeEntry({citationForm: {seh: '-aji'}, morphType: MorphTypeKind.Suffix}); + const result = classifyDuplicateCheckResults([byLexeme, byCitation], vernQueries('-aji'), writingSystems); + expect(result[0]).toMatchObject({kind: 'same-word', field: 'headword'}); + expect(result[1]).toMatchObject({kind: 'same-word', field: 'headword'}); + }); + + it('reports a suffix typed without its token as same-word, attributed to the lexeme', () => { + // typing 'aji' against suffix entry with headword '-aji': the headword only matched loosely, + // but the bare lexeme matched exactly — that's still same-word, just not a headword hit + const suffixEntry = makeEntry({lexemeForm: {seh: 'aji'}, morphType: MorphTypeKind.Suffix}); + const result = classifyDuplicateCheckResults([suffixEntry], vernQueries('aji'), writingSystems); + 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( + classifyDuplicateCheckResults([accented, plain], vernQueries('cafe'), writingSystems).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 = new Map( + classifyDuplicateCheckResults([plain, accented], vernQueries('café'), writingSystems).map(m => [m.entry.id, m.kind])); + expect(typedAccented.get(accented.id)).toBe('same-word'); + expect(typedAccented.get(plain.id)).toBe('similar-word'); + }); + + it('reports an exact lexeme hit as same-word even when it only loosely matches the citation form', () => { + // 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 result = classifyDuplicateCheckResults([viaLexemeOnly], vernQueries('fuz'), writingSystems); + expect(result[0]).toMatchObject({kind: 'same-word', field: 'lexeme'}); + }); + + 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 = classifyDuplicateCheckResults([entry], vernQueries('fuz', 'fuza'), writingSystems); + expect(result[0]).toEqual({entry, kind: 'same-word', field: 'headword'}); + }); + + it('classifies gloss overlap as similar-meaning', () => { + const entry = withGloss('cabana', 'houses'); + expect(classifyDuplicateCheckResults([entry], analysisQuery('house'), writingSystems)[0].kind).toBe('similar-meaning'); + }); + + 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 typedEntry = {lexemeForm: {seh: 'nyumba'}, citationForm: {}}; + const queries = getDuplicateCheckQueries(typedEntry, undefined, writingSystems); + const candidate = withGloss('cabana', 'nyumba'); + expect(classifyDuplicateCheckResults([candidate], queries, writingSystems)).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 queries = { + vernacular: [{bare: 'nyumba', exact: 'nyumba', wsId: 'seh'}], + analysis: ['house'], + }; + const result = classifyDuplicateCheckResults([meaning, similarA, similarB, exact], queries, writingSystems); + expect(result.map(m => m.entry.id)).toEqual([exact.id, similarA.id, similarB.id, meaning.id]); + }); +}); diff --git a/frontend/viewer/src/lib/entry-editor/duplicate-check/duplicate-check.ts b/frontend/viewer/src/lib/entry-editor/duplicate-check/duplicate-check.ts new file mode 100644 index 0000000000..1f31dd2251 --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/duplicate-check/duplicate-check.ts @@ -0,0 +1,178 @@ +import type {IEntry, ISense} from '$lib/dotnet-types'; +import type {WritingSystemService} from '$project/data'; + +/** + * 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 DuplicateCheckMatchKind = 'same-word' | 'similar-word' | 'similar-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'; + +/** The slice of WritingSystemService the duplicate check reads (kept small so tests can fake it). */ +export type DuplicateCheckWritingSystems = Pick; + +export interface DuplicateCheckMatch { + entry: IEntry; + kind: DuplicateCheckMatchKind; + field?: SameWordField; +} + +export interface VernacularDuplicateCheckQuery { + /** Diacritic-stripped, case-folded for most forgiving backend results. */ + bare: string; + /** Case-folded, 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 DuplicateCheckQueries { + /** Texts the user typed into vernacular fields (lexeme form, citation form) */ + vernacular: VernacularDuplicateCheckQuery[]; + /** Texts the user typed into gloss fields */ + analysis: string[]; +} + +/** 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(); +} + +const MAX_SIMILAR_LENGTH_DELTA = 3; + +/** + * 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 simple 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 { + if (!a || !b) return false; + const [shorter, longer] = a.length <= b.length ? [a, b] : [b, a]; + // small words get a smaller allowance (their own length), so 'a' matches 'an' but not 'and' + const maxDelta = Math.min(shorter.length, MAX_SIMILAR_LENGTH_DELTA); + return longer.length - shorter.length <= maxDelta && longer.includes(shorter); +} + +const kindRank: Record = { + 'same-word': 0, + 'similar-word': 1, + 'similar-meaning': 2, +}; + +export function normalizeForLooseCompare(value: string | undefined): string { + return normalizeForExactCompare(value).toLowerCase() + // remove diacritics + .replace(/\p{Mn}/gu, ''); +} + +export function normalizeForExactCompare(value: string | undefined): string { + return value?.normalize('NFD').trim() ?? ''; +} + +export function getDuplicateCheckQueries( + entry: Pick, + sense: Pick | undefined, + writingSystems: DuplicateCheckWritingSystems, +): DuplicateCheckQueries { + const vernacular: VernacularDuplicateCheckQuery[] = []; + for (const { wsId } of writingSystems.vernacularNoAudio) { + for (const value of [...new Set([entry.lexemeForm?.[wsId], entry.citationForm?.[wsId]])]) { + const text = normalizeForLooseCompare(value); + if (!text) continue; + const exact = normalizeForExactCompare(value); + vernacular.push({bare: text, exact, wsId}); + } + } + + const analysis: string[] = []; + for (const value of writingSystems.analysisNoAudio.map(ws => sense?.gloss?.[ws.wsId])) { + const normalized = normalizeForLooseCompare(value); + if (!normalized) 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; + }); +} + +/** + * Classifies each candidate against the queries, returning matches strongest-first by kind + * (same-word > similar-word > similar-meaning). `candidates` must be in search-relevance order: + * that order is preserved among equally-ranked matches (JS sort is stable). + */ +export function classifyDuplicateCheckResults( + candidates: IEntry[], + queries: DuplicateCheckQueries, + writingSystems: DuplicateCheckWritingSystems, +): DuplicateCheckMatch[] { + const vernacularWsIds = writingSystems.vernacularNoAudio.map(ws => ws.wsId); + const analysisWsIds = writingSystems.analysisNoAudio.map(ws => ws.wsId); + const vernQueries = queries.vernacular; + const analysisQueries = queries.analysis; + + function matchesExactly(forms: string[]): boolean { + return vernQueries.some(query => forms.some(form => normalizeForExactCompare(form) === 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 hasSimilarForm(forms: string[]): boolean { + const normalized = forms.map(form => normalizeForLooseCompare(form)); + return vernQueries.some(query => normalized.some(form => isSimilarWord(form, query.bare))); + } + + function hasSimilarMeaning(entry: IEntry): boolean { + const glosses = (entry.senses ?? []) + .flatMap(sense => analysisWsIds.map(wsId => sense.gloss?.[wsId])) + .filter((gloss): gloss is string => !!gloss) + .map(gloss => normalizeForLooseCompare(gloss)); + return analysisQueries.some(query => glosses.some(gloss => isSimilarWord(gloss, query))); + } + + function classify(entry: IEntry): {kind: DuplicateCheckMatchKind, field?: SameWordField} | undefined { + const headwordForms = vernacularWsIds.map(wsId => writingSystems.headword(entry, wsId)) + .filter((form): form is string => !!form); + const lexemeForms = vernacularWsIds.map((wsId => entry.lexemeForm?.[wsId])) + .filter((form): form is string => !!form); + + const field = sameWordField(headwordForms, lexemeForms); + if (field) return {kind: 'same-word', field}; + + if (hasSimilarForm([...headwordForms, ...lexemeForms])) + return {kind: 'similar-word'}; + + if (hasSimilarMeaning(entry)) + return {kind: 'similar-meaning'}; + 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])) + .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..d3227b0644 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/duplicate-check/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/duplicate-check/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/duplicate-check/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 @@ -381,6 +400,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/duplicate-check/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/duplicate-check/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 +450,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/duplicate-check/DuplicateSummaryPill.svelte #: src/project/tasks/SubjectPopup.svelte msgid "Close" msgstr "Close" @@ -512,6 +546,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/duplicate-check/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/duplicate-check/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 +1183,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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +msgid "Go to word" +msgstr "Go to word" + #. Filter option label #. Relevant view: Classic #. Lite view equivalent: "Part of speech" @@ -1626,6 +1688,20 @@ msgstr "No server" msgid "No server configured" msgstr "No server configured" +#. Relevant view: Classic +#. Lite view equivalent: "No similar words found" +#. Status line in the New Entry dialog: the duplicate check found no existing entries like the one being typed +#: src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte +msgid "No similar entries found" +msgstr "No similar entries found" + +#. Relevant view: Lite +#. Classic view equivalent: "No similar entries found" +#. Status line in the New Word dialog: the duplicate check found no existing words like the one being typed +#: src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte +msgid "No similar words found" +msgstr "No similar words 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 +2080,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/duplicate-check/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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +#: src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte +msgid "Same word" +msgstr "Same word" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Save" msgstr "Save" @@ -2149,6 +2247,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/duplicate-check/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/duplicate-check/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/duplicate-check/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/duplicate-check/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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +msgid "Similar words already exist" +msgstr "Similar words already exist" + #. View option #: src/project/browse/EntryListViewOptions.svelte msgid "Simple" @@ -2351,6 +2491,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/duplicate-check/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 +2535,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/duplicate-check/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..f0b7ec99d8 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/duplicate-check/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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +msgid "A similar word already exists" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "an entry" #: src/project/browse/BrowseView.svelte @@ -386,6 +405,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/duplicate-check/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/duplicate-check/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 +455,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/duplicate-check/DuplicateSummaryPill.svelte #: src/project/tasks/SubjectPopup.svelte msgid "Close" msgstr "Cerrar" @@ -517,6 +551,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/duplicate-check/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/duplicate-check/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 +1188,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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +msgid "Go to word" +msgstr "" + #. Filter option label #. Relevant view: Classic #. Lite view equivalent: "Part of speech" @@ -1631,6 +1693,20 @@ msgstr "Sin servidor" msgid "No server configured" msgstr "Ningún servidor configurado" +#. Relevant view: Classic +#. Lite view equivalent: "No similar words found" +#. Status line in the New Entry dialog: the duplicate check found no existing entries like the one being typed +#: src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte +msgid "No similar entries found" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "No similar entries found" +#. Status line in the New Word dialog: the duplicate check found no existing words like the one being typed +#: src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte +msgid "No similar words 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 +2085,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/duplicate-check/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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +#: src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte +msgid "Same word" +msgstr "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Save" msgstr "" @@ -2154,6 +2252,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/duplicate-check/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/duplicate-check/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/duplicate-check/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/duplicate-check/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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +msgid "Similar words already exist" +msgstr "" + #. View option #: src/project/browse/EntryListViewOptions.svelte msgid "Simple" @@ -2356,6 +2496,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/duplicate-check/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 +2540,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/duplicate-check/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..aa47e7a88e 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/duplicate-check/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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +msgid "A similar word already exists" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "an entry" #: src/project/browse/BrowseView.svelte @@ -386,6 +405,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/duplicate-check/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/duplicate-check/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 +455,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/duplicate-check/DuplicateSummaryPill.svelte #: src/project/tasks/SubjectPopup.svelte msgid "Close" msgstr "Fermer" @@ -517,6 +551,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/duplicate-check/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/duplicate-check/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 +1188,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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +msgid "Go to word" +msgstr "" + #. Filter option label #. Relevant view: Classic #. Lite view equivalent: "Part of speech" @@ -1631,6 +1693,20 @@ msgstr "Aucun serveur" msgid "No server configured" msgstr "Pas de serveur configuré" +#. Relevant view: Classic +#. Lite view equivalent: "No similar words found" +#. Status line in the New Entry dialog: the duplicate check found no existing entries like the one being typed +#: src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte +msgid "No similar entries found" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "No similar entries found" +#. Status line in the New Word dialog: the duplicate check found no existing words like the one being typed +#: src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte +msgid "No similar words 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 +2085,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/duplicate-check/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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +#: src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte +msgid "Same word" +msgstr "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Save" msgstr "" @@ -2154,6 +2252,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/duplicate-check/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/duplicate-check/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/duplicate-check/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/duplicate-check/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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +msgid "Similar words already exist" +msgstr "" + #. View option #: src/project/browse/EntryListViewOptions.svelte msgid "Simple" @@ -2356,6 +2496,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/duplicate-check/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 +2540,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/duplicate-check/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..8cd72ee1c6 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/duplicate-check/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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +msgid "A similar word already exists" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "an entry" #: src/project/browse/BrowseView.svelte @@ -386,6 +405,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/duplicate-check/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/duplicate-check/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 +455,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/duplicate-check/DuplicateSummaryPill.svelte #: src/project/tasks/SubjectPopup.svelte msgid "Close" msgstr "Tutup" @@ -517,6 +551,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/duplicate-check/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/duplicate-check/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 +1188,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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +msgid "Go to word" +msgstr "" + #. Filter option label #. Relevant view: Classic #. Lite view equivalent: "Part of speech" @@ -1631,6 +1693,20 @@ msgstr "Tidak ada server" msgid "No server configured" msgstr "Tidak ada server yang dikonfigurasi" +#. Relevant view: Classic +#. Lite view equivalent: "No similar words found" +#. Status line in the New Entry dialog: the duplicate check found no existing entries like the one being typed +#: src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte +msgid "No similar entries found" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "No similar entries found" +#. Status line in the New Word dialog: the duplicate check found no existing words like the one being typed +#: src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte +msgid "No similar words 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 +2085,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/duplicate-check/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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +#: src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte +msgid "Same word" +msgstr "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Save" msgstr "" @@ -2154,6 +2252,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/duplicate-check/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/duplicate-check/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/duplicate-check/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/duplicate-check/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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +msgid "Similar words already exist" +msgstr "" + #. View option #: src/project/browse/EntryListViewOptions.svelte msgid "Simple" @@ -2356,6 +2496,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/duplicate-check/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 +2540,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/duplicate-check/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..a583527635 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/duplicate-check/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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +msgid "A similar word already exists" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "an entry" #: src/project/browse/BrowseView.svelte @@ -386,6 +405,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/duplicate-check/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/duplicate-check/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 +455,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/duplicate-check/DuplicateSummaryPill.svelte #: src/project/tasks/SubjectPopup.svelte msgid "Close" msgstr "닫기" @@ -517,6 +551,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/duplicate-check/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/duplicate-check/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 +1188,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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +msgid "Go to word" +msgstr "" + #. Filter option label #. Relevant view: Classic #. Lite view equivalent: "Part of speech" @@ -1631,6 +1693,20 @@ msgstr "서버 없음" msgid "No server configured" msgstr "구성된 서버 없음" +#. Relevant view: Classic +#. Lite view equivalent: "No similar words found" +#. Status line in the New Entry dialog: the duplicate check found no existing entries like the one being typed +#: src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte +msgid "No similar entries found" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "No similar entries found" +#. Status line in the New Word dialog: the duplicate check found no existing words like the one being typed +#: src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte +msgid "No similar words 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 +2085,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/duplicate-check/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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +#: src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte +msgid "Same word" +msgstr "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Save" msgstr "" @@ -2154,6 +2252,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/duplicate-check/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/duplicate-check/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/duplicate-check/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/duplicate-check/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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +msgid "Similar words already exist" +msgstr "" + #. View option #: src/project/browse/EntryListViewOptions.svelte msgid "Simple" @@ -2356,6 +2496,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/duplicate-check/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 +2540,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/duplicate-check/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..24e259a05e 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/duplicate-check/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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +msgid "A similar word already exists" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "an entry" #: src/project/browse/BrowseView.svelte @@ -386,6 +405,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/duplicate-check/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/duplicate-check/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 +455,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/duplicate-check/DuplicateSummaryPill.svelte #: src/project/tasks/SubjectPopup.svelte msgid "Close" msgstr "Tutup" @@ -517,6 +551,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/duplicate-check/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/duplicate-check/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 +1188,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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +msgid "Go to word" +msgstr "" + #. Filter option label #. Relevant view: Classic #. Lite view equivalent: "Part of speech" @@ -1631,6 +1693,20 @@ msgstr "Tiada pelayan" msgid "No server configured" msgstr "Tiada pelayan dikonfigurasikan" +#. Relevant view: Classic +#. Lite view equivalent: "No similar words found" +#. Status line in the New Entry dialog: the duplicate check found no existing entries like the one being typed +#: src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte +msgid "No similar entries found" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "No similar entries found" +#. Status line in the New Word dialog: the duplicate check found no existing words like the one being typed +#: src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte +msgid "No similar words 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 +2085,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/duplicate-check/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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +#: src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte +msgid "Same word" +msgstr "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Save" msgstr "" @@ -2154,6 +2252,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/duplicate-check/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/duplicate-check/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/duplicate-check/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/duplicate-check/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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +msgid "Similar words already exist" +msgstr "" + #. View option #: src/project/browse/EntryListViewOptions.svelte msgid "Simple" @@ -2356,6 +2496,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/duplicate-check/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 +2540,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/duplicate-check/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..241d5e31e7 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/duplicate-check/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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +msgid "A similar word already exists" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "an entry" #: src/project/browse/BrowseView.svelte @@ -386,6 +405,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/duplicate-check/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/duplicate-check/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 +455,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/duplicate-check/DuplicateSummaryPill.svelte #: src/project/tasks/SubjectPopup.svelte msgid "Close" msgstr "Funga" @@ -517,6 +551,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/duplicate-check/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/duplicate-check/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 +1188,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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +msgid "Go to word" +msgstr "" + #. Filter option label #. Relevant view: Classic #. Lite view equivalent: "Part of speech" @@ -1631,6 +1693,20 @@ msgstr "Hakuna seva" msgid "No server configured" msgstr "Hakuna seva iliyotengwa" +#. Relevant view: Classic +#. Lite view equivalent: "No similar words found" +#. Status line in the New Entry dialog: the duplicate check found no existing entries like the one being typed +#: src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte +msgid "No similar entries found" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "No similar entries found" +#. Status line in the New Word dialog: the duplicate check found no existing words like the one being typed +#: src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte +msgid "No similar words 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 +2085,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/duplicate-check/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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +#: src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte +msgid "Same word" +msgstr "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Save" msgstr "" @@ -2154,6 +2252,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/duplicate-check/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/duplicate-check/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/duplicate-check/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/duplicate-check/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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +msgid "Similar words already exist" +msgstr "" + #. View option #: src/project/browse/EntryListViewOptions.svelte msgid "Simple" @@ -2356,6 +2496,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/duplicate-check/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 +2540,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/duplicate-check/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..c0a7805c59 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/duplicate-check/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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +msgid "A similar word already exists" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "an entry" #: src/project/browse/BrowseView.svelte @@ -386,6 +405,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/duplicate-check/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/duplicate-check/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 +455,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/duplicate-check/DuplicateSummaryPill.svelte #: src/project/tasks/SubjectPopup.svelte msgid "Close" msgstr "Đóng" @@ -517,6 +551,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/duplicate-check/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/duplicate-check/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 +1188,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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +msgid "Go to word" +msgstr "" + #. Filter option label #. Relevant view: Classic #. Lite view equivalent: "Part of speech" @@ -1631,6 +1693,20 @@ 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: "No similar words found" +#. Status line in the New Entry dialog: the duplicate check found no existing entries like the one being typed +#: src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte +msgid "No similar entries found" +msgstr "" + +#. Relevant view: Lite +#. Classic view equivalent: "No similar entries found" +#. Status line in the New Word dialog: the duplicate check found no existing words like the one being typed +#: src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte +msgid "No similar words 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 +2085,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/duplicate-check/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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +#: src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte +msgid "Same word" +msgstr "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Save" msgstr "" @@ -2154,6 +2252,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/duplicate-check/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/duplicate-check/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/duplicate-check/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/duplicate-check/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/duplicate-check/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/duplicate-check/DuplicateCheck.svelte +msgid "Similar words already exist" +msgstr "" + #. View option #: src/project/browse/EntryListViewOptions.svelte msgid "Simple" @@ -2356,6 +2496,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/duplicate-check/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 +2540,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/duplicate-check/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..5f5d7c93d4 --- /dev/null +++ b/frontend/viewer/tests/ui/new-entry-duplicates.test.ts @@ -0,0 +1,187 @@ +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|words) found/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; expand it to see the matches + await expect(stripSummary(dialog)).toBeVisible(); + await stripSummary(dialog).click(); + // '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(); + // the first row starts expanded + 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('Enter inside the duplicate strip expands the row without creating the entry', async ({page}) => { + const projectPage = new DemoProjectPage(page); + await projectPage.goto(); + + const dialog = await openNewEntryDialog(page); + await lexemeInput(dialog).fill(existingLexeme); + await stripSummary(dialog).click(); + + const duplicateRow = duplicateRows(dialog).filter({hasText: existingLexeme}).first(); + // the first row starts expanded + await expect(duplicateRow).toHaveAttribute('aria-expanded', 'true'); + await duplicateRow.focus(); + await page.keyboard.press('Enter'); + await expect(duplicateRow).toHaveAttribute('aria-expanded', 'false'); + await page.keyboard.press('Enter'); + await expect(duplicateRow).toHaveAttribute('aria-expanded', 'true'); + + // Enter did not submit the new entry form + await expect(dialog).toBeVisible(); + }); + + 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 — a similar-word match + 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); + await summary.click(); + // 3 = the component's initial display count + await expect(rows).toHaveCount(3); + await dialog.getByRole('button', {name: /show \d+ more/i}).click(); + await expect.poll(() => 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(); + // 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 expect(duplicateRows(dialog).first()).not.toBeInViewport(); + 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(); + }); +});