From 413714105be09cee4527027b0a9082e98d111c42 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Thu, 12 Mar 2026 15:29:45 +0700 Subject: [PATCH 1/6] Initial work on morph types in UI Morph types now show leading/trailing tokens in headword, but do not yet have a dropdown for editing them in the entry UI. --- .../Services/MiniLcmJsInvokable.cs | 7 +++ .../Services/IMiniLcmJsInvokable.ts | 2 + frontend/viewer/src/lib/dotnet-types/index.ts | 1 + .../src/project/data/morph-types.svelte.ts | 60 +++++++++++++++++++ .../data/writing-system-service.svelte.ts | 13 ++-- 5 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 frontend/viewer/src/project/data/morph-types.svelte.ts diff --git a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs index 48a6967d98..bac07f2ac0 100644 --- a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs +++ b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs @@ -89,6 +89,13 @@ public ValueTask GetComplexFormTypes() return _wrappedApi.GetComplexFormType(id); } + [JSInvokable] + public ValueTask GetMorphTypes() + { + return _wrappedApi.GetMorphTypes().ToArrayAsync(); + } + // TODO: Decide whether we'll need GetMorphTypeData(Guid id), but probably not since there are 19 morph types and unlikely to ever be more, so the frontend will just load all of them + [JSInvokable] public Task CountEntries(string? query, FilterQueryOptions? options) { diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts index 01e9b7e597..57d829054f 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts @@ -9,6 +9,7 @@ import type {IPartOfSpeech} from '../../MiniLcm/Models/IPartOfSpeech'; import type {IPublication} from '../../MiniLcm/Models/IPublication'; import type {ISemanticDomain} from '../../MiniLcm/Models/ISemanticDomain'; import type {IComplexFormType} from '../../MiniLcm/Models/IComplexFormType'; +import type {IMorphType} from '../../MiniLcm/Models/IMorphType'; import type {IFilterQueryOptions} from '../../MiniLcm/IFilterQueryOptions'; import type {IIndexQueryOptions} from '../../MiniLcm/IIndexQueryOptions'; import type {IEntry} from '../../MiniLcm/Models/IEntry'; @@ -31,6 +32,7 @@ export interface IMiniLcmJsInvokable getSemanticDomains() : Promise; getComplexFormTypes() : Promise; getComplexFormType(id: string) : Promise; + getMorphTypes() : Promise; countEntries(query?: string, options?: IFilterQueryOptions) : Promise; getEntryIndex(id: string, query?: string, options?: IIndexQueryOptions) : Promise; getEntries(options?: IQueryOptions) : Promise; diff --git a/frontend/viewer/src/lib/dotnet-types/index.ts b/frontend/viewer/src/lib/dotnet-types/index.ts index 65e8a379ad..d9f7f08536 100644 --- a/frontend/viewer/src/lib/dotnet-types/index.ts +++ b/frontend/viewer/src/lib/dotnet-types/index.ts @@ -12,6 +12,7 @@ export * from './generated-types/MiniLcm/Models/IComplexFormType'; export * from './generated-types/MiniLcm/Models/IEntry'; export * from './generated-types/MiniLcm/Models/IExampleSentence'; export * from './generated-types/MiniLcm/Models/ITranslation'; +export * from './generated-types/MiniLcm/Models/IMorphType'; export * from './generated-types/MiniLcm/Models/IObjectWithId'; export * from './generated-types/MiniLcm/Models/IPartOfSpeech'; export * from './generated-types/MiniLcm/Models/IProjectIdentifier'; diff --git a/frontend/viewer/src/project/data/morph-types.svelte.ts b/frontend/viewer/src/project/data/morph-types.svelte.ts new file mode 100644 index 0000000000..d94d754afa --- /dev/null +++ b/frontend/viewer/src/project/data/morph-types.svelte.ts @@ -0,0 +1,60 @@ +import type {MorphTypeKind, IMorphType} from '$lib/dotnet-types'; + +import {type ProjectContext, useProjectContext} from '$project/project-context.svelte'; +import {type ResourceReturn} from 'runed'; + +const morphTypesSymbol = Symbol.for('fw-lite-morph-types'); +export function useMorphTypesService(): MorphTypesService { + const projectContext = useProjectContext(); + return projectContext.getOrAdd(morphTypesSymbol, () => { + return new MorphTypesService(projectContext); + }); +} + +export class MorphTypesService { + constructor(projectContext: ProjectContext) { + this.#morphTypesResource = projectContext.apiResource([], api => api.getMorphTypes()); + } + + #morphTypesResource: ResourceReturn; + + current: IMorphType[] = $derived.by(() => { + return this.#morphTypesResource.current; + }); + + async refetch() { + await this.#morphTypesResource.refetch(); + return this.current; + } + + prefixes = $derived.by(() => { + const result: Partial<{[kind in MorphTypeKind]: string|undefined}> = {}; + this.current.forEach(morphType => { + result[morphType.kind] = morphType.prefix; + }); + return result; + }); + + suffixes = $derived.by(() => { + const result: Partial<{[kind in MorphTypeKind]: string|undefined}> = {}; + this.current.forEach(morphType => { + result[morphType.kind] = morphType.postfix; + }); + return result; + }); + + getPrefix(kind: MorphTypeKind): string|undefined { + return this.prefixes[kind]; + } + + getSuffix(kind: MorphTypeKind): string|undefined { + return this.suffixes[kind]; + } + + decorate(headword: string | undefined, kind: MorphTypeKind): string|undefined { + if (!headword) return headword; + const prefix = this.getPrefix(kind) ?? ''; + const suffix = this.getSuffix(kind) ?? ''; + return `${prefix}${headword}${suffix}`; + } +} 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 efd89cd0fb..016d2f2141 100644 --- a/frontend/viewer/src/project/data/writing-system-service.svelte.ts +++ b/frontend/viewer/src/project/data/writing-system-service.svelte.ts @@ -13,6 +13,7 @@ import {type ProjectContext, useProjectContext} from '$project/project-context.s import {type ResourceReturn} from 'runed'; import type {View} from '$lib/views/view-data'; import type {ReadonlyDeep} from 'type-fest'; +import {type MorphTypesService, useMorphTypesService} from './morph-types.svelte'; export type WritingSystemSelection = | 'vernacular' @@ -26,7 +27,8 @@ export type WritingSystemSelection = const symbol = Symbol.for('fw-lite-ws-service'); export function useWritingSystemService(): WritingSystemService { const projectContext = useProjectContext(); - return projectContext.getOrAdd(symbol, () => new WritingSystemService(projectContext)); + const morphTypesService = useMorphTypesService(); + return projectContext.getOrAdd(symbol, () => new WritingSystemService(projectContext, morphTypesService)); } export class WritingSystemService { @@ -37,7 +39,10 @@ export class WritingSystemService { return this.#wsResource.current; } - constructor(projectContext: ProjectContext) { + #morphTypesService: MorphTypesService; + + constructor(projectContext: ProjectContext, morphTypesService: MorphTypesService) { + this.#morphTypesService = morphTypesService; this.#wsResource = projectContext.apiResource({analysis: [], vernacular: []}, async api => { const result = await api.getWritingSystems(); return { @@ -122,10 +127,10 @@ export class WritingSystemService { headword(entry: ReadonlyDeep, ws?: string): string { if (ws) { - return headword(entry, ws) || ''; + return this.#morphTypesService.decorate(headword(entry, ws), entry.morphType) || ''; } - return firstTruthy(this.vernacularNoAudio, ws => headword(entry, ws.wsId)) || ''; + return firstTruthy(this.vernacularNoAudio, ws => this.#morphTypesService.decorate(headword(entry, ws.wsId), entry.morphType)) || ''; } pickBestAlternative(value: IMultiString, wss: 'vernacular' | 'analysis'): string From 62bd17438cc13593e202bc2bbf3ab6c4d51fa822 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Tue, 24 Mar 2026 12:46:31 +0700 Subject: [PATCH 2/6] Citation forms should not be decorated Lexeme forms should be decorated with prefix/postfix tokens according to the morph type, but citation forms are meant as "overrides" and should be reproduced exactly as-is, without morph type tokens. This is the rule used by FLEx for how it displays words, so FW Lite should do the same. As a bonus, there is now only one `headword` function in the writing system service, instead of two functions with the same name that did two slightly different things. --- .../project/data/writing-system-service.svelte.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 016d2f2141..7f7eeae9a9 100644 --- a/frontend/viewer/src/project/data/writing-system-service.svelte.ts +++ b/frontend/viewer/src/project/data/writing-system-service.svelte.ts @@ -127,10 +127,14 @@ export class WritingSystemService { headword(entry: ReadonlyDeep, ws?: string): string { if (ws) { - return this.#morphTypesService.decorate(headword(entry, ws), entry.morphType) || ''; + return this.#decorated(entry, ws) || ''; } + return firstTruthy(this.vernacularNoAudio, ws => this.#decorated(entry, ws.wsId)) || ''; + } - return firstTruthy(this.vernacularNoAudio, ws => this.#morphTypesService.decorate(headword(entry, ws.wsId), entry.morphType)) || ''; + #decorated(entry: ReadonlyDeep, ws: string): string | undefined { + // Citation forms should not be decorated with prefix/postfix tokens, only lexeme forms get decorated + return entry.citationForm[ws] || this.#morphTypesService.decorate(entry.lexemeForm[ws], entry.morphType); } pickBestAlternative(value: IMultiString, wss: 'vernacular' | 'analysis'): string @@ -212,10 +216,6 @@ type WritingSystemColors = { analysis: Record; } -function headword(entry: ReadonlyDeep, ws: string): string | undefined { - return entry.citationForm[ws] || entry.lexemeForm[ws]; -} - function calcWritingSystemColors(writingSystems: IWritingSystems): WritingSystemColors { const wsColors = { vernacular: {} as Record, From fcd260a5ad8b66448a4b44915b00f7190790a122 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Tue, 24 Mar 2026 13:00:01 +0700 Subject: [PATCH 3/6] Remove TODO we won't actually implement --- backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs index bac07f2ac0..230255b8bb 100644 --- a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs +++ b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs @@ -94,7 +94,6 @@ public ValueTask GetMorphTypes() { return _wrappedApi.GetMorphTypes().ToArrayAsync(); } - // TODO: Decide whether we'll need GetMorphTypeData(Guid id), but probably not since there are 19 morph types and unlikely to ever be more, so the frontend will just load all of them [JSInvokable] public Task CountEntries(string? query, FilterQueryOptions? options) From d60e88874fa3599ada4be21f39bfde2fe8c142ff Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Tue, 24 Mar 2026 16:07:01 +0700 Subject: [PATCH 4/6] Add morph types to in-memory-demo-api for testing We don't need all the morph types for testing, just a few of them. --- .../src/project/demo/demo-entry-data.ts | 31 ++++++++++++++++++- .../src/project/demo/in-memory-demo-api.ts | 19 ++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/frontend/viewer/src/project/demo/demo-entry-data.ts b/frontend/viewer/src/project/demo/demo-entry-data.ts index 1b4013567a..ade627d53a 100644 --- a/frontend/viewer/src/project/demo/demo-entry-data.ts +++ b/frontend/viewer/src/project/demo/demo-entry-data.ts @@ -1,4 +1,4 @@ -import {type IEntry, type IWritingSystems, MorphTypeKind, WritingSystemType} from '$lib/dotnet-types'; +import {type IEntry, type IMorphType, type IWritingSystems, MorphTypeKind, WritingSystemType} from '$lib/dotnet-types'; export const projectName = 'Sena 3'; @@ -30,6 +30,35 @@ export const partsOfSpeech = [ ]; +export const morphTypes: IMorphType[] = [ + { + id: 'd7f713e8-e8cf-11d3-9764-00c04f186933', + kind: MorphTypeKind.Stem, + name: {en: 'stem' }, + abbreviation: {en: 'ubd stem' }, + description: {}, + secondaryOrder: 0, + }, + { + id: 'd7f713db-e8cf-11d3-9764-00c04f186933', + kind: MorphTypeKind.Prefix, + name: {en: 'prefix' }, + abbreviation: {en: 'pfx' }, + description: {}, + postfix: '-', + secondaryOrder: 20, + }, + { + id: 'd7f713dd-e8cf-11d3-9764-00c04f186933', + kind: MorphTypeKind.Suffix, + name: {en: 'suffix' }, + abbreviation: {en: 'sfx' }, + description: {}, + prefix: '-', + secondaryOrder: 70, + }, +]; + export const writingSystems: IWritingSystems = { 'analysis': [ { 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 21e6195934..4e50f85da2 100644 --- a/frontend/viewer/src/project/demo/in-memory-demo-api.ts +++ b/frontend/viewer/src/project/demo/in-memory-demo-api.ts @@ -9,6 +9,7 @@ import { type IFilterQueryOptions, type IIndexQueryOptions, type IMiniLcmJsInvokable, + type IMorphType, type IPartOfSpeech, type IProjectModel, type IPublication, @@ -20,7 +21,7 @@ import { type IWritingSystems, type WritingSystemType } from '$lib/dotnet-types'; -import {entries, partsOfSpeech, projectName, writingSystems} from './demo-entry-data'; +import {entries, morphTypes, partsOfSpeech, projectName, writingSystems} from './demo-entry-data'; import {WritingSystemService} from '../data/writing-system-service.svelte'; import {FwLitePlatform} from '$lib/dotnet-types/generated-types/FwLiteShared/FwLitePlatform'; @@ -37,6 +38,7 @@ import type {IUpdateService} from '$lib/dotnet-types/generated-types/FwLiteShare import {type IAvailableUpdate, UpdateResult} from '$lib/dotnet-types/generated-types/FwLiteShared/AppUpdate'; import {type EventBus, useEventBus, ProjectEventBus} from '$lib/services/event-bus'; import type {IJsEventListener} from '$lib/dotnet-types/generated-types/FwLiteShared/Events/IJsEventListener'; +import {MorphTypesService} from '$project/data/morph-types.svelte'; function pickWs(ws: string, defaultWs: string): string { return ws === 'default' ? defaultWs : ws; @@ -90,10 +92,12 @@ const mockJsEventListener: IJsEventListener = { }; export class InMemoryDemoApi implements IMiniLcmJsInvokable { + #morphTypesService: MorphTypesService; #writingSystemService: WritingSystemService; #projectEventBus: ProjectEventBus; constructor(projectContext: ProjectContext, eventBus: EventBus) { - this.#writingSystemService = new WritingSystemService(projectContext); + this.#morphTypesService = new MorphTypesService(projectContext); + this.#writingSystemService = new WritingSystemService(projectContext, this.#morphTypesService); this.#projectEventBus = new ProjectEventBus(projectContext, eventBus); } @@ -154,6 +158,17 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable { ); } + getMorphTypes(): Promise { + return Promise.resolve( + morphTypes + // [ + // {id: 'd7f713e8-e8cf-11d3-9764-00c04f186933', kind: MorphTypeKind.Stem}, + // {id: 'd7f713db-e8cf-11d3-9764-00c04f186933', kind: MorphTypeKind.Prefix, postfix='-'}, + // {id: 'd7f713dd-e8cf-11d3-9764-00c04f186933', kind: MorphTypeKind.Suffix, prefix='-'}, + // ] + ); + } + getPartsOfSpeech(): Promise { return Promise.resolve( partsOfSpeech From 1f38aa25b919ec203c5c94b3edf99801e33f7afa Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 25 Mar 2026 17:19:01 +0700 Subject: [PATCH 5/6] Fix one Playwright test Now that morph types show prefix and postfix tokens, "-a" was sorting before "-UPDATED-" so the updated entry was no longer the first one in the list. --- frontend/viewer/tests/entries-list.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/viewer/tests/entries-list.test.ts b/frontend/viewer/tests/entries-list.test.ts index ebb0208930..428387efa8 100644 --- a/frontend/viewer/tests/entries-list.test.ts +++ b/frontend/viewer/tests/entries-list.test.ts @@ -160,12 +160,12 @@ test.describe('EntriesList', () => { expect(firstEntryText).toBeTruthy(); // Update first entry by prepending to its headword (so it stays at index 0) - const {updatedHeadword} = await api.updateEntryHeadwordPrepend(0, '-UPDATED-'); + const {updatedHeadword} = await api.updateEntryHeadwordPrepend(0, '---UPDATED---'); // The first entry in UI should now show the updated text await expect(async () => { const newFirstEntryText = await entriesList.entryRows.first().textContent(); - expect(newFirstEntryText).toContain('-UPDATED-'); + expect(newFirstEntryText).toContain('---UPDATED---'); }).toPass({timeout: 5000}); await expect(entriesList.entryWithText(updatedHeadword)).toBeVisible(); From 6eab38b0ed0a838fb094c988428d563b61ca0895 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 27 Mar 2026 17:32:58 +0100 Subject: [PATCH 6/6] Fix tests --- frontend/viewer/src/project/demo/in-memory-demo-api.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 4e50f85da2..e5f93a6ebd 100644 --- a/frontend/viewer/src/project/demo/in-memory-demo-api.ts +++ b/frontend/viewer/src/project/demo/in-memory-demo-api.ts @@ -239,8 +239,9 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable { const sortWs = pickWs(options.order.writingSystem, defaultWs); return entries .sort((e1, e2) => { - const v1 = this.#writingSystemService.headword(e1, sortWs); - const v2 = this.#writingSystemService.headword(e2, sortWs); + // morph-tokens should not be included when sorting + const v1 = e1.citationForm[sortWs] || e1.lexemeForm[sortWs]; + const v2 = e2.citationForm[sortWs] || e2.lexemeForm[sortWs]; if (!v2) return -1; if (!v1) return 1; let compare = v1.localeCompare(v2, sortWs);