-
-
Notifications
You must be signed in to change notification settings - Fork 6
Add live duplicate check to the new-entry dialog #2411
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
f794f8f
Add live possible-duplicates check to the new-entry dialog
myieye ad8bfa5
Guard against concurrent duplicate-row actions (CodeRabbit review)
myieye 96f287f
Align duplicate classification with backend match semantics
myieye 92321dd
Expand duplicate rows in place and add an out-of-view jump pill
myieye 2dc41fb
Merge remote-tracking branch 'origin/develop' into feat/possible-dupl…
myieye File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
294 changes: 294 additions & 0 deletions
294
frontend/viewer/src/lib/entry-editor/DuplicateCheck.svelte
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,294 @@ | ||
| <script lang="ts" module> | ||
| export interface DuplicateSummary { | ||
| count: number; | ||
| capped: boolean; | ||
| hasExactWordMatch: boolean; | ||
| /** Matched headwords, strongest first, comma-joined for one-line display */ | ||
| previewHeadwords: string; | ||
| /** One-line banner text, shared by this widget's header and the host's jump pill */ | ||
| message: string; | ||
| } | ||
| </script> | ||
|
|
||
| <script lang="ts"> | ||
| import type {IEntry, ISense} from '$lib/dotnet-types'; | ||
| import {SortField} from '$lib/dotnet-types'; | ||
| import {resource, watch} from 'runed'; | ||
| import {t} from 'svelte-i18n-lingui'; | ||
| import {slide} from 'svelte/transition'; | ||
| import {navigate, useRouter} from 'svelte-routing'; | ||
| import * as Collapsible from '$lib/components/ui/collapsible'; | ||
| import {Badge} from '$lib/components/ui/badge'; | ||
| import {Icon} from '$lib/components/ui/icon'; | ||
| import {Button} from '$lib/components/ui/button'; | ||
| import DictionaryEntry from '$lib/components/dictionary/DictionaryEntry.svelte'; | ||
| import Loading from '$lib/components/Loading.svelte'; | ||
| import {AppNotification} from '$lib/notifications/notifications'; | ||
| import {useSaveHandler} from '$lib/services/save-event-service.svelte'; | ||
| import {useLexboxApi} from '$lib/services/service-provider'; | ||
| import {useMorphTypesService} from '$project/data/morph-types.svelte'; | ||
| import {useWritingSystemService} from '$project/data'; | ||
| import {useViewService} from '$lib/views/view-service.svelte'; | ||
| import {pt} from '$lib/views/view-text'; | ||
| import {entryBrowseParams} from '$lib/utils/search-params'; | ||
| import {DEFAULT_DEBOUNCE_TIME} from '$lib/utils/time'; | ||
| import {classifyDuplicates, duplicateQueries, duplicateTintClass, mergeSearchResults, trapEnter, type DuplicateMatch, type DuplicateQueries} from './duplicate-check'; | ||
|
|
||
| interface Props { | ||
| entry: IEntry; | ||
| sense?: ISense; | ||
| /** Called right before navigating to an existing entry, so the host dialog can close itself. */ | ||
| onNavigateToEntry?: (entry: IEntry) => void; | ||
| /** True while an add-sense save is in flight — the host dialog should block submitting until it settles. */ | ||
| busy?: boolean; | ||
| /** Set while there are matches, so the host can render an out-of-view indicator. */ | ||
| summary?: DuplicateSummary; | ||
| } | ||
|
|
||
| let {entry, sense, onNavigateToEntry, busy = $bindable(false), summary = $bindable()}: Props = $props(); | ||
|
|
||
| const lexboxApi = useLexboxApi(); | ||
| const writingSystemService = useWritingSystemService(); | ||
| const morphTypesService = useMorphTypesService(); | ||
| const viewService = useViewService(); | ||
| const saveHandler = useSaveHandler(); | ||
| const {base} = useRouter(); | ||
|
|
||
| // Over-fetch: the full-text search also returns cross-field hits (e.g. a typed lexeme that | ||
| // coincidentally equals some entry's gloss) which classifyDuplicates drops, so fetch extra | ||
| // to keep enough real matches after filtering. | ||
| const FETCH_COUNT = 20; | ||
| const INITIAL_DISPLAY_COUNT = 3; | ||
|
|
||
| const vernacularWsIds = $derived(writingSystemService.vernacularNoAudio.map(ws => ws.wsId)); | ||
| const analysisWsIds = $derived(writingSystemService.analysisNoAudio.map(ws => ws.wsId)); | ||
| const queries = $derived(duplicateQueries(entry, sense, vernacularWsIds, analysisWsIds)); | ||
| const hasQueries = $derived(queries.vernacular.length + queries.analysis.length > 0); | ||
|
|
||
| const duplicatesResource = resource( | ||
| // string key, so edits to unrelated fields don't retrigger the search | ||
| () => JSON.stringify([queries.vernacular, queries.analysis]), | ||
| async (_key, _prev, {signal}): Promise<{candidates: IEntry[], queries: DuplicateQueries, capped: boolean} | undefined> => { | ||
| // rank each search by the writing system the text was typed in, so that WS's headword matches sort first | ||
| const searches = [ | ||
| ...queries.vernacular.map(query => ({text: query.text, writingSystem: query.wsId})), | ||
| ...queries.analysis.map(text => ({text, writingSystem: 'default'})), | ||
| ]; | ||
| if (!searches.length) return undefined; | ||
| const results = await Promise.all(searches.map(search => lexboxApi.searchEntries(search.text, { | ||
| offset: 0, | ||
| count: FETCH_COUNT, | ||
| order: {field: SortField.SearchRelevance, writingSystem: search.writingSystem, ascending: true}, | ||
| }))); | ||
| // searchEntries can't take the abort signal over JSInterop and `resource` keeps whatever | ||
| // resolves last, so discard results that a newer keystroke has already superseded | ||
| if (signal.aborted) throw new DOMException('superseded duplicate search', 'AbortError'); | ||
| return { | ||
| candidates: mergeSearchResults(results), | ||
| // the queries these candidates answer — the live `queries` may already be newer | ||
| queries, | ||
| capped: results.some(result => result.length >= FETCH_COUNT), | ||
| }; | ||
| }, | ||
| {debounce: DEFAULT_DEBOUNCE_TIME}, | ||
| ); | ||
|
|
||
| // Classification lives outside the resource so it tracks the lazy morph-types resource: | ||
| // reading it here starts its load at mount, and once loaded matches re-classify without re-searching. | ||
| const matches = $derived.by(() => { | ||
| const morphTypes = morphTypesService.current; | ||
| const result = duplicatesResource.current; | ||
| if (!result) return undefined; | ||
| return classifyDuplicates(result.candidates, result.queries, vernacularWsIds, analysisWsIds, morphTypes); | ||
| }); | ||
| const hasExactWordMatch = $derived(!!matches?.some(match => match.kind === 'same-word')); | ||
| // Matched headwords (strongest first) shown in the collapsed header, truncated by the | ||
| // trigger's ellipsis, so users can dismiss a wall of loose matches at a glance. | ||
| const previewHeadwords = $derived([...new Set( | ||
| (matches ?? []).map(match => writingSystemService.headword(match.entry)).filter(Boolean), | ||
| )].join(', ')); | ||
| const summaryMessage = $derived.by(() => { | ||
| if (hasExactWordMatch) return pt($t`This entry may already exist`, $t`This word may already exist`, viewService.currentView); | ||
| if (matches?.length === 1) return pt($t`A similar entry already exists`, $t`A similar word already exists`, viewService.currentView); | ||
| return pt($t`Similar entries already exist`, $t`Similar words already exist`, viewService.currentView); | ||
| }); | ||
| $effect(() => { | ||
| summary = matches?.length | ||
| ? {count: matches.length, capped: !!duplicatesResource.current?.capped, hasExactWordMatch, previewHeadwords, message: summaryMessage} | ||
| : undefined; | ||
| }); | ||
|
|
||
| let expanded = $state(false); | ||
| let userToggled = $state(false); | ||
|
|
||
| /** Opens the match list, counting as a user toggle (the host's jump-pill calls this). */ | ||
| export function expand(): void { | ||
| expanded = true; | ||
| userToggled = true; | ||
| } | ||
| let displayCount = $state(INITIAL_DISPLAY_COUNT); | ||
| let expandedEntryId = $state<string>(); | ||
| const displayedMatches = $derived(matches?.slice(0, displayCount) ?? []); | ||
|
|
||
| // Unfold automatically when the word itself already exists — that's the "stop and look" case. | ||
| // A manual collapse/expand always wins afterwards. | ||
| $effect(() => { | ||
| if (hasExactWordMatch && !userToggled) expanded = true; | ||
| }); | ||
| watch(() => matches, current => { | ||
| if (!current?.length) { | ||
| expanded = false; | ||
| userToggled = false; | ||
| displayCount = INITIAL_DISPLAY_COUNT; | ||
| expandedEntryId = undefined; | ||
| } | ||
| }); | ||
|
|
||
| function kindLabel(match: DuplicateMatch): string { | ||
| switch (match.kind) { | ||
| case 'same-word': | ||
| // a lexeme-only match on an entry whose citation form differs must not claim "Same | ||
| // headword" — the row displays that (different) citation form as the headword | ||
| return match.field === 'lexeme' | ||
| ? pt($t`Same lexeme form`, $t`Same word`, viewService.currentView) | ||
| : pt($t`Same headword`, $t`Same word`, viewService.currentView); | ||
| case 'similar-word': | ||
| return pt($t`Similar headword`, $t`Similar word`, viewService.currentView); | ||
| case 'same-meaning': | ||
| return pt($t`Similar gloss`, $t`Similar meaning`, viewService.currentView); | ||
| } | ||
| } | ||
|
|
||
| function openEntry(target: IEntry): void { | ||
| onNavigateToEntry?.(target); | ||
| navigate(`${$base.uri}/browse?${entryBrowseParams(target.id)}`); | ||
| } | ||
|
|
||
| // Rescues the meaning the user already typed: instead of creating a duplicate entry, | ||
| // it becomes a new sense of the existing one. | ||
| const canAddSense = $derived(!!sense && !!writingSystemService.firstDefOrGlossVal(sense)); | ||
|
|
||
| async function addSenseToEntry(target: IEntry): Promise<void> { | ||
| if (!sense || busy) return; | ||
| busy = true; | ||
| try { | ||
| // fresh id: the dialog's sense id must never end up on two entries (e.g. add-sense then create) | ||
| const senseSnapshot = {...$state.snapshot(sense), id: crypto.randomUUID(), entryId: target.id}; | ||
| await saveHandler.handleSave(() => lexboxApi.createSense(target.id, senseSnapshot)); | ||
| } finally { | ||
| busy = false; | ||
| } | ||
| AppNotification.display( | ||
| pt($t`Sense added to "${writingSystemService.headword(target)}"`, | ||
| $t`Meaning added to "${writingSystemService.headword(target)}"`, | ||
| viewService.currentView), | ||
| {type: 'success', timeout: 'short'}); | ||
| openEntry(target); | ||
| } | ||
|
|
||
| </script> | ||
|
|
||
| <div class="min-h-9 flex flex-col justify-center w-full" aria-live="polite"> | ||
| {#if !matches?.length} | ||
| {#if (duplicatesResource.loading && hasQueries) || matches} | ||
| <div class="flex items-center gap-2 px-1 text-sm text-muted-foreground" transition:slide={{duration: 150}}> | ||
| {#if duplicatesResource.loading} | ||
| <Loading class="size-4" /> | ||
| {pt($t`Checking for similar entries…`, $t`Checking for similar words…`, viewService.currentView)} | ||
| {:else} | ||
| <Icon icon="i-mdi-check-circle-outline" class="size-4 text-green-600 dark:text-green-500" /> | ||
| {pt($t`No similar entries found`, $t`Looks like a new word`, viewService.currentView)} | ||
| {/if} | ||
| </div> | ||
| {/if} | ||
| {:else} | ||
| <Collapsible.Root | ||
| bind:open={expanded} | ||
| onOpenChange={() => userToggled = true} | ||
| class="rounded-md border {duplicateTintClass(hasExactWordMatch)}" | ||
| > | ||
| <Collapsible.Trigger class="w-full flex items-center gap-2 px-3 py-2 text-sm cursor-pointer" onkeydown={trapEnter}> | ||
| {#if hasExactWordMatch} | ||
| <Icon icon="i-mdi-alert-circle-outline" class="size-5 shrink-0 text-amber-600 dark:text-amber-400" /> | ||
| {:else} | ||
| <Icon icon="i-mdi-information-outline" class="size-5 shrink-0 text-muted-foreground" /> | ||
| {/if} | ||
| <span class="grow min-w-0 truncate text-start font-medium"> | ||
| {summaryMessage} | ||
| {#if !expanded && previewHeadwords} | ||
| <span class="text-muted-foreground font-normal">— {previewHeadwords}</span> | ||
| {/if} | ||
| </span> | ||
| {#if duplicatesResource.loading} | ||
| <Loading class="size-4" /> | ||
| {/if} | ||
| <Badge variant="secondary">{matches.length}{duplicatesResource.current?.capped ? '+' : ''}</Badge> | ||
| <Icon icon={expanded ? 'i-mdi-chevron-up' : 'i-mdi-chevron-down'} class="size-5 shrink-0" /> | ||
| </Collapsible.Trigger> | ||
| <Collapsible.Content> | ||
| <ul class="px-1.5 pb-1.5 space-y-1.5 max-h-56 overflow-y-auto"> | ||
| {#each displayedMatches as match (match.entry.id)} | ||
| {@const badge = kindLabel(match)} | ||
| {@const isExpanded = expandedEntryId === match.entry.id} | ||
| <li class="rounded bg-background/80"> | ||
| <button | ||
| type="button" | ||
| class="w-full flex items-center gap-2 {isExpanded ? 'rounded-t' : 'rounded'} hover:bg-accent px-2.5 py-2 text-start" | ||
| aria-expanded={isExpanded} | ||
| onkeydown={trapEnter} | ||
| onclick={() => expandedEntryId = isExpanded ? undefined : match.entry.id}> | ||
| <div class="grow min-w-0 text-sm {isExpanded ? '' : 'line-clamp-1'}"> | ||
| <DictionaryEntry entry={match.entry} inline={!isExpanded} hideExamples={!isExpanded} /> | ||
| </div> | ||
| {#if badge} | ||
| <Badge variant="outline" class="shrink-0 self-start whitespace-nowrap {match.kind === 'same-word' ? 'border-amber-600/50 dark:border-amber-400/50' : ''}"> | ||
| {badge} | ||
| </Badge> | ||
| {/if} | ||
| <Icon icon={isExpanded ? 'i-mdi-chevron-up' : 'i-mdi-chevron-down'} class="size-4 shrink-0 self-start mt-0.5 text-muted-foreground" /> | ||
| </button> | ||
| {#if isExpanded} | ||
| <div class="flex flex-wrap justify-end gap-1.5 px-2.5 pt-1 pb-2" transition:slide={{duration: 150}}> | ||
| {#if canAddSense && (match.kind === 'same-word' || match.kind === 'similar-word')} | ||
| {@const addSenseLabel = pt($t`Add sense`, $t`Add meaning`, viewService.currentView)} | ||
| {@const addSenseHint = pt($t`Add sense to this entry`, $t`Add meaning to this word`, viewService.currentView)} | ||
| <Button | ||
| variant="outline" | ||
| size="sm" | ||
| icon="i-mdi-playlist-plus" | ||
| title={addSenseHint} | ||
| aria-label={addSenseHint} | ||
| disabled={busy} | ||
| onkeydown={trapEnter} | ||
| onclick={() => addSenseToEntry(match.entry)}> | ||
| {addSenseLabel} | ||
| </Button> | ||
| {/if} | ||
| <Button | ||
| variant="outline" | ||
| size="sm" | ||
| icon="i-mdi-arrow-right" | ||
| disabled={busy} | ||
| onkeydown={trapEnter} | ||
| onclick={() => openEntry(match.entry)}> | ||
| {pt($t`Go to entry`, $t`Go to word`, viewService.currentView)} | ||
| </Button> | ||
| </div> | ||
| {/if} | ||
| </li> | ||
| {/each} | ||
| {#if matches.length > displayedMatches.length} | ||
| {@const remainingEntries = matches.length - displayedMatches.length} | ||
| <li> | ||
| <Button variant="ghost" size="sm" class="w-full text-muted-foreground" | ||
| onkeydown={trapEnter} | ||
| onclick={() => displayCount = matches.length}> | ||
| {$t`Show ${remainingEntries} more...`} | ||
| </Button> | ||
| </li> | ||
| {/if} | ||
| </ul> | ||
| </Collapsible.Content> | ||
| </Collapsible.Root> | ||
| {/if} | ||
| </div> | ||
55 changes: 55 additions & 0 deletions
55
frontend/viewer/src/lib/entry-editor/DuplicateSummaryPill.svelte
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| <script lang="ts"> | ||
| import {t} from 'svelte-i18n-lingui'; | ||
| import {Badge} from '$lib/components/ui/badge'; | ||
| import {Icon} from '$lib/components/ui/icon'; | ||
| import {duplicateTintClass, trapEnter} from './duplicate-check'; | ||
| import type {DuplicateSummary} from './DuplicateCheck.svelte'; | ||
|
|
||
| interface Props { | ||
| summary: DuplicateSummary; | ||
| /** Called when the pill body is activated — the host scrolls the duplicate widget into view. */ | ||
| onJump: () => void; | ||
| onDismiss: () => void; | ||
| } | ||
|
|
||
| let {summary, onJump, onDismiss}: Props = $props(); | ||
| </script> | ||
|
|
||
| <!-- One visual pill, two sibling buttons (a button can't nest a button). | ||
| mousedown preventDefault: focusing the pill makes Chromium cancel the smooth scroll it triggers --> | ||
| <!-- opaque bg-background underlay: the tint colors are translucent washes shared with | ||
| the duplicate widget's trigger, and the pill floats over form content --> | ||
| <div class="pointer-events-auto max-w-full rounded-full bg-background shadow-md"> | ||
| <div class="max-w-full flex items-center rounded-full border text-sm {duplicateTintClass(summary.hasExactWordMatch)}"> | ||
| <button | ||
| type="button" | ||
| aria-label={summary.message} | ||
| class="min-w-0 flex items-center gap-2 rounded-s-full ps-3 py-1.5 relative after:absolute after:content-[''] after:-inset-y-2.5 after:-start-2.5 after:end-0" | ||
| onkeydown={trapEnter} | ||
| onmousedown={e => e.preventDefault()} | ||
| onclick={onJump}> | ||
| {#if summary.hasExactWordMatch} | ||
| <Icon icon="i-mdi-alert-circle-outline" class="size-4 shrink-0 text-amber-600 dark:text-amber-400" /> | ||
| {:else} | ||
| <Icon icon="i-mdi-information-outline" class="size-4 shrink-0 text-muted-foreground" /> | ||
| {/if} | ||
| <span class="min-w-0 truncate font-medium"> | ||
| {summary.message} | ||
| {#if summary.previewHeadwords} | ||
| <span class="text-muted-foreground font-normal">— {summary.previewHeadwords}</span> | ||
| {/if} | ||
| </span> | ||
| <Badge variant="secondary">{summary.count}{summary.capped ? '+' : ''}</Badge> | ||
| <Icon icon="i-mdi-chevron-down" class="size-4 shrink-0" /> | ||
| </button> | ||
| <button | ||
| type="button" | ||
| aria-label={$t`Close`} | ||
| class="flex items-center rounded-e-full ps-1.5 pe-2.5 py-1.5 self-stretch relative after:absolute after:content-[''] after:-inset-y-2.5 after:start-0 after:-end-2.5" | ||
| onkeydown={trapEnter} | ||
| onmousedown={e => e.preventDefault()} | ||
| onclick={onDismiss}> | ||
| <Icon icon="i-mdi-close" class="size-4 shrink-0" /> | ||
| </button> | ||
| </div> | ||
| </div> |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Race: clicking "go to entry" on another row while an add-sense save is pending can trigger a surprise second navigation.
addingSenseonly disables the add-sense icon buttons; the "go to entry" buttons stay clickable. If a user clicks a different match's "go to entry" whileaddSenseToEntryis still awaitingcreateSense, the dialog closes and navigation happens immediately — but once the pending save resolves,addSenseToEntrystill callsopenEntry(target)again, silently re-navigating the user to a different entry than the one they just chose.🛠️ Suggested fix
<button type="button" class="grow min-w-0 flex items-center gap-2 rounded bg-background/80 hover:bg-accent px-2.5 py-2 text-start" title={goToLabel} aria-label={headword ? `${goToLabel}: ${headword}` : goToLabel} + disabled={addingSense} onkeydown={trapEnter} onclick={() => openEntry(match.entry)}>Also applies to: 176-193
🤖 Prompt for AI Agents