diff --git a/.gitignore b/.gitignore index 0bff6ff928..f9286a2e5b 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ msal.cache artifacts/ .vs/ project-cache.json +preferences.json localResourcesCache/ #Verify diff --git a/frontend/viewer/src/lib/components/dictionary/DictionaryEntry.svelte b/frontend/viewer/src/lib/components/dictionary/DictionaryEntry.svelte index cc45467a79..eef244918c 100644 --- a/frontend/viewer/src/lib/components/dictionary/DictionaryEntry.svelte +++ b/frontend/viewer/src/lib/components/dictionary/DictionaryEntry.svelte @@ -6,6 +6,7 @@ import {cn} from '$lib/utils'; import type {Snippet} from 'svelte'; import Headwords from './Headwords.svelte'; + import {useViewService} from '$lib/views/view-service.svelte'; let { entry, showLinks = false, @@ -16,6 +17,7 @@ highlightSenseId = undefined, hideExamples = false, inline = false, + respectView = true, ...restProps }: HTMLAttributes & { entry: IEntry; @@ -27,6 +29,8 @@ hideExamples?: boolean; /** Render senses as one flowing line (no line break per sense) — for compact previews */ inline?: boolean; + /** false shows every writing system, not just the current view's */ + respectView?: boolean; } = $props(); $effect(() => { @@ -34,6 +38,13 @@ }); const wsService = useWritingSystemService(); + const viewService = useViewService(); + const vernacularWs = $derived( + respectView ? wsService.viewVernacularNoAudio(viewService.currentView) : wsService.vernacularNoAudio, + ); + const analysisWs = $derived( + (respectView ? wsService.viewAnalysis(viewService.currentView) : wsService.analysis).filter((ws) => !ws.isAudio), + ); let senses = $derived(entry.senses.map(getRenderedContent)); @@ -47,8 +58,7 @@ return { id: sense.id, partOfSpeech: partsOfSpeech.current.find((pos) => pos.id === sense.partOfSpeechId)?.label, - glossesAndDefs: wsService.analysis - .filter((ws) => !ws.isAudio) + glossesAndDefs: analysisWs .map((ws) => ({ wsId: ws.wsId, wsAbbr: ws.abbreviation, @@ -60,18 +70,14 @@ exampleSentences: sense.exampleSentences.map((example) => ({ id: example.id, sentences: [ - ...wsService.vernacular - .filter((ws) => !ws.isAudio) - .map((ws) => ({ - text: asString(example.sentence[ws.wsId]), - color: wsService.wsColor(ws.wsId, 'vernacular'), - })), - ...wsService.analysis - .filter((ws) => !ws.isAudio) - .map((ws) => ({ - text: asString(example.translations[0]?.text?.[ws.wsId]), - color: wsService.wsColor(ws.wsId, 'analysis'), - })), + ...vernacularWs.map((ws) => ({ + text: asString(example.sentence[ws.wsId]), + color: wsService.wsColor(ws.wsId, 'vernacular'), + })), + ...analysisWs.map((ws) => ({ + text: asString(example.translations[0]?.text?.[ws.wsId]), + color: wsService.wsColor(ws.wsId, 'analysis'), + })), ].filter(({text}) => !!text), })), }; @@ -104,7 +110,7 @@
{@render actions?.()}
- + {#each senses as sense, i (sense.id)} {#if senses.length > 1} {#if inline} diff --git a/frontend/viewer/src/lib/components/dictionary/Headwords.svelte b/frontend/viewer/src/lib/components/dictionary/Headwords.svelte index 0232afc518..91d39cd8a3 100644 --- a/frontend/viewer/src/lib/components/dictionary/Headwords.svelte +++ b/frontend/viewer/src/lib/components/dictionary/Headwords.svelte @@ -1,31 +1,43 @@ diff --git a/frontend/viewer/src/lib/entry-editor/EntryOrSensePicker.svelte b/frontend/viewer/src/lib/entry-editor/EntryOrSensePicker.svelte index 7cfd95b12d..bdbf2ffc56 100644 --- a/frontend/viewer/src/lib/entry-editor/EntryOrSensePicker.svelte +++ b/frontend/viewer/src/lib/entry-editor/EntryOrSensePicker.svelte @@ -63,7 +63,7 @@ const collapsedSelectionPreview = $derived.by(() => { if (!selectedEntry) return ''; if (!selectedSense) { - return writingSystemService.headword(selectedEntry) || ''; + return writingSystemService.viewBestHeadword(selectedEntry, viewService.currentView) || ''; } return writingSystemService.firstGloss(selectedSense) || writingSystemService.firstDef(selectedSense) || ''; }); diff --git a/frontend/viewer/src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte b/frontend/viewer/src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte index 49b0603827..b515402d15 100644 --- a/frontend/viewer/src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte +++ b/frontend/viewer/src/lib/entry-editor/duplicate-check/DuplicateCheck.svelte @@ -127,7 +127,7 @@ }); const hasExactWordMatch = $derived(!!matches?.some((match) => match.kind === 'same-word')); const previewHeadwords = $derived( - [...new Set((matches ?? []).map((match) => writingSystemService.headword(match.entry)).filter(Boolean))].join(', '), + [...new Set((matches ?? []).map((match) => writingSystemService.firstHeadword(match.entry)).filter(Boolean))].join(', '), ); const summaryMessage = $derived.by(() => { if (hasExactWordMatch) @@ -263,7 +263,8 @@ onclick={() => (expandedEntryId = isExpanded ? undefined : match.entry.id)} >
- + +
{#each selectedEntryHistory as selected (selected.entry.id)}

- Entry: {writingSystemService.headword(selected.entry)} + Entry: {writingSystemService.firstHeadword(selected.entry)} {#if selected.sense} Sense: {writingSystemService.firstGloss(selected.sense)} {/if} diff --git a/frontend/viewer/src/project/browse/BrowseView.svelte b/frontend/viewer/src/project/browse/BrowseView.svelte index 7feedd7158..cf46b6b40b 100644 --- a/frontend/viewer/src/project/browse/BrowseView.svelte +++ b/frontend/viewer/src/project/browse/BrowseView.svelte @@ -25,9 +25,11 @@ import UnreadCommentBadge from '$project/browse/filter/UnreadCommentBadge.svelte'; import {QueryParamStateBool} from '$lib/utils/url.svelte'; import {watch} from 'runed'; + import {useWritingSystemService} from '$project/data'; const projectContext = useProjectContext(); const viewService = useViewService(); + const writingSystemService = useWritingSystemService(); const dialogsService = useDialogsService(); const features = useFeatures(); const entryListViewMode = useProjectStorage().entryListViewMode; @@ -49,9 +51,14 @@ }, false); let sort = $state(); // Writing system to sort/display by, chosen separately from the sort field/direction. - // Undefined = the default vernacular (how it works today). + // Undefined = the current view's first vernacular. let sortWs = $state(); - const sortWithWs = $derived(sort ? {...sort, writingSystem: sortWs} : undefined); + // The user's pick is kept, but only applies while the current view shows that writing system. + const effectiveSortWs = $derived.by(() => { + const viewWritingSystems = writingSystemService.viewVernacularNoAudio(viewService.currentView); + return viewWritingSystems.find(ws => ws.wsId === sortWs)?.wsId ?? viewWritingSystems[0]?.wsId; + }); + const sortWithWs = $derived(sort ? {...sort, writingSystem: effectiveSortWs} : undefined); const entryMode: EntryListViewMode = $derived(entryListViewMode.current === 'preview' ? 'preview' : 'simple'); // Turning the filter on means the comments are what the user came for, so open the @@ -98,7 +105,7 @@

search ? SortField.SearchRelevance : SortField.Headword} /> - + effectiveSortWs, (v) => sortWs = v} /> {#if features.comments} {/if} diff --git a/frontend/viewer/src/project/browse/EntryMenu.svelte b/frontend/viewer/src/project/browse/EntryMenu.svelte index 247d8e395a..c086673736 100644 --- a/frontend/viewer/src/project/browse/EntryMenu.svelte +++ b/frontend/viewer/src/project/browse/EntryMenu.svelte @@ -29,7 +29,7 @@ children?: Snippet }>(); - const headword = $derived((entry && writingSystemService.headword(entry)) || $t`Untitled`); + const headword = $derived((entry && writingSystemService.viewBestHeadword(entry, viewService.currentView)) || $t`Untitled`); let open = $state(false); diff --git a/frontend/viewer/src/project/browse/EntryRow.svelte b/frontend/viewer/src/project/browse/EntryRow.svelte index 830917fee5..bfcadb4600 100644 --- a/frontend/viewer/src/project/browse/EntryRow.svelte +++ b/frontend/viewer/src/project/browse/EntryRow.svelte @@ -7,6 +7,7 @@ import type {WithoutChildrenOrChild} from 'bits-ui'; import type {Snippet} from 'svelte'; import {t} from 'svelte-i18n-lingui'; + import {useViewService} from '$lib/views/view-service.svelte'; interface Props extends WithoutChildrenOrChild { entry?: IEntry; @@ -27,7 +28,8 @@ const writingSystemService = useWritingSystemService(); const partOfSpeechService = usePartsOfSpeech(); - const sensePreview = $derived(writingSystemService.firstDefOrGlossVal(entry?.senses?.[0])); + const viewService = useViewService(); + const sensePreview = $derived(writingSystemService.viewFirstDefOrGlossVal(entry?.senses?.[0], viewService.currentView)); const partOfSpeech = $derived(entry?.senses?.[0]?.partOfSpeech); // Generate random widths for skeleton UI elements @@ -43,6 +45,10 @@ // Calculate animation delay based on index (staggered effect) const animationDelay = `${(Math.random() * 5) * 0.15}s`; + + const defaultWs = $derived(sortWs + ?? writingSystemService.viewVernacularNoAudio(viewService.currentView)[0]?.wsId + ?? ''); @@ -55,8 +61,16 @@ {:else if previewDictionary} {:else} + + {@const best = writingSystemService.viewBestHeadwordIn(entry, viewService.currentView, defaultWs)} + {@const fallbackWs = best && best.ws.wsId !== defaultWs ? best.ws : undefined}

- {writingSystemService.bestHeadword(entry, sortWs) || $t`Untitled`} + + {#if fallbackWs} + {fallbackWs.abbreviation || fallbackWs.wsId} + {/if} + {best?.value || $t`Untitled`} + {@render badge?.()}

{#if entry.senses.length} diff --git a/frontend/viewer/src/project/browse/EntryView.svelte b/frontend/viewer/src/project/browse/EntryView.svelte index 7ce25f0205..29b3e94f07 100644 --- a/frontend/viewer/src/project/browse/EntryView.svelte +++ b/frontend/viewer/src/project/browse/EntryView.svelte @@ -109,7 +109,7 @@ let latestPersistedSnapshot = $state>(); const entryPersistence = new EntryPersistence(() => latestPersistedSnapshot); let entry = $derived(entryResource.current ?? undefined); - const headword = $derived((entry && writingSystemService.headword(entry)) || $t`Untitled`); + const headword = $derived((entry && writingSystemService.viewBestHeadword(entry, viewService.currentView)) || $t`Untitled`); const loadingDebounced = new Debounced(() => entryResource.loading, 50); const dictionaryPreview: DictionaryPreviewMode = $derived( isDictionaryPreviewMode(dictionaryPreviewStorage.current) ? dictionaryPreviewStorage.current : 'show' diff --git a/frontend/viewer/src/project/browse/sort/SortWritingSystemMenu.svelte b/frontend/viewer/src/project/browse/sort/SortWritingSystemMenu.svelte index 79083a2495..b4383c4cc9 100644 --- a/frontend/viewer/src/project/browse/sort/SortWritingSystemMenu.svelte +++ b/frontend/viewer/src/project/browse/sort/SortWritingSystemMenu.svelte @@ -5,18 +5,20 @@ import {cn} from '$lib/utils'; import {useWritingSystemService} from '$project/data'; import {Button, buttonVariants} from '$lib/components/ui/button'; + import {useViewService} from '$lib/views/view-service.svelte'; type Props = { - /** Selected writing system id to sort/display by. Undefined = the default vernacular. */ + /** Selected writing system id to sort/display by. Undefined = the current view's first vernacular. */ value?: string; }; let {value = $bindable()}: Props = $props(); const writingSystemService = useWritingSystemService(); - // Vernacular writing systems, excluding audio — the ones a headword can sort by. - const writingSystems = $derived(writingSystemService.vernacularNoAudio); - const selectedWsId = $derived(value ?? writingSystemService.defaultVernacular?.wsId); + const viewService = useViewService(); + // The current view's vernacular writing systems, excluding audio — the ones a headword can sort by. + const writingSystems = $derived(writingSystemService.viewVernacularNoAudio(viewService.currentView)); + const selectedWsId = $derived(value ?? writingSystems[0]?.wsId); const selectedWs = $derived(writingSystems.find(ws => ws.wsId === selectedWsId)); diff --git a/frontend/viewer/src/project/data/writing-system-service.svelte.test.ts b/frontend/viewer/src/project/data/writing-system-service.svelte.test.ts index 000fb65bb9..3f279d4b48 100644 --- a/frontend/viewer/src/project/data/writing-system-service.svelte.test.ts +++ b/frontend/viewer/src/project/data/writing-system-service.svelte.test.ts @@ -1,7 +1,9 @@ +import type {IEntry, ISense, IWritingSystem, IWritingSystems} from '$lib/dotnet-types'; import {describe, expect, it} from 'vitest'; -import type {IWritingSystem, IWritingSystems} from '$lib/dotnet-types'; -import type {ProjectContext} from '$project/project-context.svelte'; + import type {MorphTypesService} from './morph-types.svelte'; +import type {ProjectContext} from '$project/project-context.svelte'; +import type {View} from '$lib/views/view-data'; import {WritingSystemService} from './writing-system-service.svelte'; // The dedup only looks at wsId, so a minimal stub is enough (distinct object per call so we can @@ -10,11 +12,32 @@ function ws(wsId: string): IWritingSystem { return {wsId} as unknown as IWritingSystem; } -function serviceWith(vernacular: IWritingSystem[], analysis: IWritingSystem[]): WritingSystemService { +function audioWs(wsId: string): IWritingSystem { + return {wsId, isAudio: true} as unknown as IWritingSystem; +} + +function viewWith({vernacular, analysis}: {vernacular?: string[]; analysis?: string[]} = {}): View { + return { + vernacular: vernacular?.map((wsId) => ({wsId})), + analysis: analysis?.map((wsId) => ({wsId})), + } as unknown as View; +} + +function entryWith({lexemeForm = {}, citationForm = {}}: {lexemeForm?: Record; citationForm?: Record}): IEntry { + return {lexemeForm, citationForm, senses: []} as unknown as IEntry; +} + +function senseWith({gloss = {}, definition = {}}: {gloss?: Record; definition?: Record}): ISense { + return {gloss, definition} as unknown as ISense; +} + +// Decoration is the morph-type service's job; the identity stub keeps expectations readable. +const morphTypes = {decorate: (form: string | undefined) => form} as unknown as MorphTypesService; + +function serviceWith(vernacular: IWritingSystem[], analysis: IWritingSystem[] = []): WritingSystemService { const writingSystems: IWritingSystems = {vernacular, analysis}; - // Only #wsResource.current is read on the writing-system-selection path; morphTypesService is unused there. const projectContext = {apiResource: () => ({current: writingSystems})} as unknown as ProjectContext; - return new WritingSystemService(projectContext, {} as unknown as MorphTypesService); + return new WritingSystemService(projectContext, morphTypes); } describe('uniqueWritingSystems', () => { @@ -45,3 +68,126 @@ describe('uniqueWritingSystems', () => { expect(result[0]).toBe(analysisEn); }); }); + +describe('viewVernacularNoAudio', () => { + const service = serviceWith([ws('seh'), audioWs('seh-audio'), ws('ny')]); + function wsIds(view: View) { + return service.viewVernacularNoAudio(view).map((w) => w.wsId); + } + + it("keeps the view's text writing systems and drops audio ones", () => { + expect(wsIds(viewWith({vernacular: ['ny', 'seh-audio']}))).toEqual(['ny']); + }); + + it('uses every text vernacular when the view does not restrict writing systems', () => { + expect(wsIds(viewWith())).toEqual(['seh', 'ny']); + }); + + it('falls back to every text vernacular when the view only shows audio', () => { + expect(wsIds(viewWith({vernacular: ['seh-audio']}))).toEqual(['seh', 'ny']); + }); +}); + +describe('headwords', () => { + // Project vernaculars in project order: seh, ny, en (+ an audio one, which never carries a headword) + const service = serviceWith([ws('seh'), audioWs('seh-audio'), ws('ny'), ws('en')]); + + describe('headword (one writing system, no fallback)', () => { + it('returns the form in that writing system', () => { + const entry = entryWith({lexemeForm: {seh: 'casa', ny: 'nyumba'}}); + + expect(service.headword(entry, 'ny')).toBe('nyumba'); + }); + + it('prefers the citation form over the lexeme form', () => { + const entry = entryWith({lexemeForm: {seh: 'casa'}, citationForm: {seh: 'casas'}}); + + expect(service.headword(entry, 'seh')).toBe('casas'); + }); + + it('is empty when the entry has no form there, even if other writing systems do', () => { + const entry = entryWith({lexemeForm: {seh: 'casa'}}); + + expect(service.headword(entry, 'ny')).toBe(''); + }); + }); + + describe('firstHeadword (any text vernacular, project order)', () => { + it('returns the first vernacular with a form, in project order', () => { + const entry = entryWith({lexemeForm: {ny: 'nyumba', en: 'house'}}); + + expect(service.firstHeadword(entry)).toBe('nyumba'); + }); + + it('skips audio writing systems', () => { + const entry = entryWith({lexemeForm: {'seh-audio': 'casa.wav', en: 'house'}}); + + expect(service.firstHeadword(entry)).toBe('house'); + }); + + it('is empty when the entry has no form anywhere', () => { + expect(service.firstHeadword(entryWith({}))).toBe(''); + }); + }); + + describe("viewBestHeadword (the given or the first of the view's vernaculars, otherwise any)", () => { + it('prefers the given writing system', () => { + const entry = entryWith({lexemeForm: {seh: 'casa', ny: 'nyumba'}}); + + expect(service.viewBestHeadword(entry, viewWith({vernacular: ['seh', 'ny']}), 'ny')).toBe('nyumba'); + }); + + it("prefers the view's default writing system when none is given", () => { + const entry = entryWith({lexemeForm: {seh: 'casa', ny: 'nyumba'}}); + + expect(service.viewBestHeadword(entry, viewWith({vernacular: ['ny', 'en']}))).toBe('nyumba'); + }); + + it("then falls back to the view's other vernaculars, in project order, before any hidden one", () => { + const entry = entryWith({lexemeForm: {seh: 'casa', en: 'house'}}); + + expect(service.viewBestHeadword(entry, viewWith({vernacular: ['ny', 'en']}), 'ny')).toBe('house'); + }); + + it('then falls back to a vernacular the view hides', () => { + const entry = entryWith({lexemeForm: {seh: 'casa'}}); + + expect(service.viewBestHeadword(entry, viewWith({vernacular: ['ny']}), 'ny')).toBe('casa'); + }); + + it('is empty when the entry has no form anywhere', () => { + expect(service.viewBestHeadword(entryWith({}), viewWith({vernacular: ['ny']}))).toBe(''); + }); + }); + + describe('viewBestHeadwordIn (viewBestHeadword plus the writing system it came from)', () => { + it('names the writing system the headword came from', () => { + const entry = entryWith({lexemeForm: {seh: 'casa'}}); + + const best = service.viewBestHeadwordIn(entry, viewWith({vernacular: ['ny']}), 'ny'); + + expect(best?.value).toBe('casa'); + expect(best?.ws.wsId).toBe('seh'); + }); + + it('is undefined when the entry has no form anywhere', () => { + expect(service.viewBestHeadwordIn(entryWith({}), viewWith())).toBeUndefined(); + }); + }); +}); + +describe('viewFirstDefOrGlossVal', () => { + const service = serviceWith([], [ws('en'), ws('pt')]); + + it("prefers the view's analysis writing systems", () => { + const sense = senseWith({gloss: {en: 'house', pt: 'casa'}}); + + expect(service.viewFirstDefOrGlossVal(sense, viewWith({analysis: ['pt']}))).toBe('casa'); + }); + + it('falls back to every analysis writing system when the view has no value', () => { + const sense = senseWith({gloss: {en: 'house'}}); + + expect(service.viewFirstDefOrGlossVal(sense, viewWith({analysis: ['pt']}))).toBe('house'); + }); +}); diff --git a/frontend/viewer/src/project/data/writing-system-service.svelte.ts b/frontend/viewer/src/project/data/writing-system-service.svelte.ts index fa21413d86..7737199e1c 100644 --- a/frontend/viewer/src/project/data/writing-system-service.svelte.ts +++ b/frontend/viewer/src/project/data/writing-system-service.svelte.ts @@ -97,6 +97,16 @@ export class WritingSystemService { return this.filterWs(this.vernacular, view?.vernacular); } + /** + * The view's non-audio vernacular writing systems, or all of them if the view only shows audio ones. + * We currently only have fallback like this for vernacular, because headwords are the primary identifier in a lexicon, + * so it can be highly disorienting if none is displayed + * */ + viewVernacularNoAudio(view: View): IWritingSystem[] { + const writingSystems = this.viewVernacular(view).filter(ws => !ws.isAudio); + return writingSystems.length ? writingSystems : this.vernacularNoAudio; + } + filterWs(writingSystems: IWritingSystem[], override?: IViewWritingSystem[]) { if (!override) return writingSystems; return writingSystems.filter(ws => override.find(_ws => _ws.wsId === ws.wsId)); @@ -132,21 +142,31 @@ export class WritingSystemService { return this.defaultVernacular?.exemplars; } - headword(entry: ReadonlyDeep, ws?: string): string { - if (ws) { - return this.#decorated(entry, ws) || ''; - } + headword(entry: ReadonlyDeep, ws: string): string { + return this.#decorated(entry, ws) || ''; + } + + firstHeadword(entry: ReadonlyDeep): string { return firstTruthy(this.vernacularNoAudio, ws => this.#decorated(entry, ws.wsId)) || ''; } /** - * Headword to show in the entry list when sorting by writing system `ws`. Prefers `ws` so the - * shown headword matches the sort key, but falls back to the first available vernacular value - * (like {@link headword}) so rows aren't left blank when an entry has no form in `ws`. + * Like {@link firstHeadword}, but prefers the view's vernaculars over the rest, so the simple list + * and the dictionary preview agree on which headword an entry shows. */ - bestHeadword(entry: ReadonlyDeep, ws?: string): string { - if (!ws) return this.headword(entry); - return this.#decorated(entry, ws) || this.headword(entry); + viewBestHeadword(entry: ReadonlyDeep, view: View, ws?: string): string { + return this.viewBestHeadwordIn(entry, view, ws)?.value ?? ''; + } + + /** {@link viewBestHeadword} plus the writing system the headword came from, so a fallback can be labelled. */ + viewBestHeadwordIn(entry: ReadonlyDeep, view: View, wsId?: string): {ws: IWritingSystem, value: string} | undefined { + const viewWs = this.viewVernacularNoAudio(view); + const preferred = wsId ? this.getWritingSystem(wsId, 'vernacular') : viewWs[0]; + const candidates = [...(preferred ? [preferred] : []), ...viewWs, ...this.vernacularNoAudio]; + return firstTruthy(candidates, ws => { + const value = this.headword(entry, ws.wsId); + return value ? {ws, value} : undefined; + }); } #decorated(entry: ReadonlyDeep, ws: string): string | undefined { @@ -199,6 +219,15 @@ export class WritingSystemService { return this.first(sense.definition, this.analysis) || this.first(sense.gloss, this.analysis) || ''; } + /** Like {@link firstDefOrGlossVal}, but prefers the view's analysis writing systems. */ + viewFirstDefOrGlossVal(sense: ISense | undefined, view: View): string { + if (!sense) return ''; + const viewAnalysis = this.viewAnalysis(view).filter(ws => !ws.isAudio); + return this.first(sense.definition, viewAnalysis) + || this.first(sense.gloss, viewAnalysis) + || this.firstDefOrGlossVal(sense); + } + firstSentenceOrTranslationVal(example: IExampleSentence | undefined): string { if (!example) return ''; return this.first(example.sentence, this.vernacular) || this.firstTranslationVal(example); diff --git a/frontend/viewer/src/project/demo/in-memory-demo-api.ts b/frontend/viewer/src/project/demo/in-memory-demo-api.ts index 20268c9e21..d7b37db2d0 100644 --- a/frontend/viewer/src/project/demo/in-memory-demo-api.ts +++ b/frontend/viewer/src/project/demo/in-memory-demo-api.ts @@ -48,6 +48,7 @@ import {type EventBus, useEventBus, ProjectEventBus} from '$lib/services/event-b import type {IJsEventListener} from '$lib/dotnet-types/generated-types/FwLiteShared/Events/IJsEventListener'; import {initProjectStorage} from '$lib/storage'; import {MorphTypesService} from '$project/data/morph-types.svelte'; +import {useCustomViewService} from '$project/data/custom-view-service.svelte'; import type {ICommentThread} from '$lib/dotnet-types/generated-types/MiniLcm/Models/ICommentThread'; import type {IUserComment} from '$lib/dotnet-types/generated-types/MiniLcm/Models/IUserComment'; import type {SubjectType} from '$lib/dotnet-types/generated-types/MiniLcm/Models/SubjectType'; @@ -131,6 +132,7 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable { }); window.lexbox.ServiceProvider.setService(DotnetService.UpdateService, mockUpdateService); window.lexbox.ServiceProvider.setService(DotnetService.JsEventListener, mockJsEventListener); + const customViewService = useCustomViewService(); window.__PLAYWRIGHT_UTILS__ = { demoApi: inMemoryLexboxApi, async setWrite(write: boolean) { @@ -141,6 +143,7 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable { hasHardwareKeyboard = value; refreshPlatformFeatures(); }, + addCustomView: (customView: ICustomView) => customViewService.add(customView), }; window.lexbox.ServiceProvider.setService(DotnetService.CombinedProjectsService, { diff --git a/frontend/viewer/src/project/tasks/ReviewList.svelte b/frontend/viewer/src/project/tasks/ReviewList.svelte index 36e23962ef..932774df14 100644 --- a/frontend/viewer/src/project/tasks/ReviewList.svelte +++ b/frontend/viewer/src/project/tasks/ReviewList.svelte @@ -5,8 +5,10 @@ import type {TaskSubject} from './subject.svelte'; import {useWritingSystemService} from '$project/data'; import {t} from 'svelte-i18n-lingui'; + import {useViewService} from '$lib/views/view-service.svelte'; const writingSystemService = useWritingSystemService(); + const viewService = useViewService(); let { subjects, @@ -25,7 +27,7 @@
{#each subjects as subject (subject)} editSubject(subject)} icon="i-mdi-book-open-page-variant"> -

{writingSystemService.headword(subject.entry)}

+

{writingSystemService.viewBestHeadword(subject.entry, viewService.currentView)}

{subject.subject}

{/each} diff --git a/frontend/viewer/src/stories/editor/misc/entry-picker.stories.svelte b/frontend/viewer/src/stories/editor/misc/entry-picker.stories.svelte index bc6be742cf..d07f0e00c1 100644 --- a/frontend/viewer/src/stories/editor/misc/entry-picker.stories.svelte +++ b/frontend/viewer/src/stories/editor/misc/entry-picker.stories.svelte @@ -44,7 +44,7 @@
{#each selectedEntryHistory as selected (selected.entry.id)}

- Entry: {writingSystemService?.headword(selected.entry)} + Entry: {writingSystemService?.firstHeadword(selected.entry)} {#if selected.sense} Sense: {writingSystemService?.firstGloss(selected.sense)} {/if} diff --git a/frontend/viewer/tests/ui/sort.test.ts b/frontend/viewer/tests/ui/sort.test.ts index 6aeb8fff3a..c5fa94cd6f 100644 --- a/frontend/viewer/tests/ui/sort.test.ts +++ b/frontend/viewer/tests/ui/sort.test.ts @@ -1,6 +1,7 @@ import {expect, test} from '@playwright/test'; import {DemoProjectPage} from './demo-project.page'; +import {ViewBase} from '$lib/dotnet-types/generated-types/MiniLcm/Models/ViewBase'; test.describe('Sort by writing system', () => { let projectPage: DemoProjectPage; @@ -54,4 +55,85 @@ test.describe('Sort by writing system', () => { // The headword now shows the Chichewa form. await expect(headword).toHaveText('qtxny'); }); + + test('defaults to the first vernacular writing system of the current view', async () => { + const {page} = projectPage; + + await projectPage.api.createEntryWithForms({seh: 'qtxseh', ny: 'qtxny'}); + await projectPage.entriesList.filterByText('qtx'); + const headword = projectPage.entriesList.entryRows + .filter({hasNotText: 'Add to dictionary'}) + .locator('h2'); + await expect(headword).toHaveText('qtxseh'); + await expect(wsTrigger()).toBeVisible(); + + // A custom view that shows Chichewa as its only non-audio vernacular writing system. + await page.evaluate((base) => window.__PLAYWRIGHT_UTILS__.addCustomView({ + id: crypto.randomUUID(), + name: 'Chichewa only', + base, + entryFields: [{fieldId: 'lexemeForm'}], + senseFields: [{fieldId: 'gloss'}], + exampleFields: [{fieldId: 'sentence'}], + vernacular: [{wsId: 'ny'}], + }), ViewBase.FwLite); + await page.getByRole('button').filter({has: page.locator('.i-mdi-layers')}).click(); + await page.getByRole('radio', {name: 'Chichewa only (Lite)'}).click(); + await expect(page.getByRole('radio', {name: 'Chichewa only (Lite)'})).toBeChecked(); + await page.keyboard.press('Escape'); + + // The headword follows the view's only vernacular, so there's nothing left to pick in the pill. + await expect(headword).toHaveText('qtxny'); + await expect(wsTrigger()).toHaveCount(0); + }); + + test('a headword from outside the view is labelled with its writing system in the simple list', async () => { + const {page} = projectPage; + + await projectPage.api.createEntryWithForms({seh: 'qtxseh'}); + await projectPage.entriesList.filterByText('qtx'); + const row = projectPage.entriesList.entryRows.filter({hasNotText: 'Add to dictionary'}); + + await page.evaluate((base) => window.__PLAYWRIGHT_UTILS__.addCustomView({ + id: crypto.randomUUID(), + name: 'Chichewa only', + base, + entryFields: [{fieldId: 'lexemeForm'}], + senseFields: [{fieldId: 'gloss'}], + exampleFields: [{fieldId: 'sentence'}], + vernacular: [{wsId: 'ny'}], + }), ViewBase.FwLite); + await page.getByRole('button').filter({has: page.locator('.i-mdi-layers')}).click(); + await page.getByRole('radio', {name: 'Chichewa only (Lite)'}).click(); + await page.keyboard.press('Escape'); + + // Simple mode: the Sena form stands in for the missing Chichewa one, tagged "Sen". + await expect(row.locator('h2')).toHaveText(/^Sen\s*qtxseh\s*$/); + + // Preview mode: the form is shown untagged; its writing-system colour says which it is. + await page.getByRole('button').filter({has: page.locator('.i-mdi-layers')}).click(); + await page.getByRole('tab', {name: 'Preview'}).click(); + await page.keyboard.press('Escape'); + await expect(row.locator('strong')).toHaveText('qtxseh'); + }); + + test('a view with only audio vernaculars falls back to every text vernacular', async () => { + const {page} = projectPage; + + await projectPage.api.createEntryWithForms({seh: 'qtxseh', ny: 'qtxny'}); + await projectPage.entriesList.filterByText('qtx'); + const row = projectPage.entriesList.entryRows.filter({hasNotText: 'Add to dictionary'}); + + // The demo's "Portuguese and audio" view only shows the Sena audio vernacular. + await page.getByRole('button').filter({has: page.locator('.i-mdi-layers')}).click(); + await page.getByRole('tab', {name: 'Preview'}).click(); + await page.getByRole('button').filter({has: page.locator('.i-mdi-layers')}).click(); + await page.getByRole('radio', {name: 'Portuguese and audio (Lite)'}).click(); + await expect(page.getByRole('radio', {name: 'Portuguese and audio (Lite)'})).toBeChecked(); + await page.keyboard.press('Escape'); + + // The preview still shows headwords, sorted by and switchable between the text vernaculars. + await expect(row.locator('strong')).toHaveText('qtxseh / qtxny'); + await expect(wsTrigger()).toBeVisible(); + }); }); diff --git a/frontend/viewer/tests/ui/test.d.ts b/frontend/viewer/tests/ui/test.d.ts index d258f9b9ba..658915cf55 100644 --- a/frontend/viewer/tests/ui/test.d.ts +++ b/frontend/viewer/tests/ui/test.d.ts @@ -1,4 +1,5 @@ import type {IMiniLcmJsInvokable} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable'; +import type {ICustomView} from '$lib/dotnet-types/generated-types/MiniLcm/Models/ICustomView'; export { }; // for some reason this is required in order to make global changes @@ -10,6 +11,8 @@ declare global { /** Toggle demo write feature and refetch project features. */ setWrite: (write: boolean) => Promise; setHasHardwareKeyboard: (value: boolean) => void; + /** Create a custom view and refresh the app's list of views. */ + addCustomView: (customView: ICustomView) => Promise; }; } }