Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
headwordClass = '',
highlightSenseId = undefined,
hideExamples = false,
inline = false,
...restProps
}: HTMLAttributes<HTMLDivElement> & {
entry: IEntry;
Expand All @@ -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(() => {
Expand Down Expand Up @@ -104,7 +107,12 @@
<Headwords {entry} class={cn('mr-1', headwordClass)} />
{#each senses as sense, i (sense.id)}
{#if senses.length > 1}
<br />
{#if inline}
<!-- eslint-disable-next-line svelte/no-useless-mustaches This mustache is not useless, it preserves whitespace -->
{' '}
{:else}
<br />
{/if}
{/if}
<span class={cn(highlightSenseId === sense.id && 'rounded bg-secondary')}>
{#if senses.length > 1}
Expand Down
299 changes: 299 additions & 0 deletions frontend/viewer/src/lib/entry-editor/DuplicateCheck.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
<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 {plural, 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 || duplicatesResource.error}
<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 if duplicatesResource.error}
<!-- inline, not AppNotification.error: this search re-fires per typing pause, and a
failure toast per pause would bury the dialog; the strip already owns a status line -->
<Icon icon="i-mdi-alert-outline" class="size-4" />
{pt($t`Could not check for similar entries`, $t`Could not check 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}>
{$plural(remainingEntries, {one: 'Show # more...', other: 'Show # more...'})}
</Button>
</li>
{/if}
</ul>
</Collapsible.Content>
</Collapsible.Root>
{/if}
</div>
56 changes: 56 additions & 0 deletions frontend/viewer/src/lib/entry-editor/DuplicateSummaryPill.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<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 -->
<!-- 32rem cap: the headword preview would otherwise stretch the pill across the whole dialog -->
<div class="pointer-events-auto max-w-[min(100%,32rem)] 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>
Loading
Loading