Skip to content

Commit d9a50e5

Browse files
rmunnmyieye
andcommitted
Display morph type tokens in UI (#2205)
* 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. * 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. * Fix tests --------- Co-authored-by: Tim Haasdyk <tim_haasdyk@sil.org>
1 parent 7076b9e commit d9a50e5

8 files changed

Lines changed: 157 additions & 32 deletions

File tree

backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ public ValueTask<CustomView[]> GetCustomViews()
102102
return _wrappedApi.GetComplexFormType(id);
103103
}
104104

105+
[JSInvokable]
106+
public ValueTask<MorphType[]> GetMorphTypes()
107+
{
108+
return _wrappedApi.GetMorphTypes().ToArrayAsync();
109+
}
110+
105111
[JSInvokable]
106112
public Task<int> CountEntries(string? query, FilterQueryOptions? options)
107113
{

frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {IPublication} from '../../MiniLcm/Models/IPublication';
1010
import type {ISemanticDomain} from '../../MiniLcm/Models/ISemanticDomain';
1111
import type {IComplexFormType} from '../../MiniLcm/Models/IComplexFormType';
1212
import type {ICustomView} from '../../MiniLcm/Models/ICustomView';
13+
import type {IMorphType} from '../../MiniLcm/Models/IMorphType';
1314
import type {IFilterQueryOptions} from '../../MiniLcm/IFilterQueryOptions';
1415
import type {IIndexQueryOptions} from '../../MiniLcm/IIndexQueryOptions';
1516
import type {IEntry} from '../../MiniLcm/Models/IEntry';
@@ -34,6 +35,7 @@ export interface IMiniLcmJsInvokable
3435
getCustomViews() : Promise<ICustomView[]>;
3536
getCustomView(id: string) : Promise<ICustomView | null>;
3637
getComplexFormType(id: string) : Promise<IComplexFormType | null>;
38+
getMorphTypes() : Promise<IMorphType[]>;
3739
countEntries(query?: string, options?: IFilterQueryOptions) : Promise<number>;
3840
getEntryIndex(id: string, query?: string, options?: IIndexQueryOptions) : Promise<number>;
3941
getEntries(options?: IQueryOptions) : Promise<IEntry[]>;

frontend/viewer/src/lib/dotnet-types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export * from './generated-types/MiniLcm/Models/IComplexFormType';
1212
export * from './generated-types/MiniLcm/Models/IEntry';
1313
export * from './generated-types/MiniLcm/Models/IExampleSentence';
1414
export * from './generated-types/MiniLcm/Models/ITranslation';
15+
export * from './generated-types/MiniLcm/Models/IMorphType';
1516
export * from './generated-types/MiniLcm/Models/IObjectWithId';
1617
export * from './generated-types/MiniLcm/Models/IPartOfSpeech';
1718
export * from './generated-types/MiniLcm/Models/IProjectIdentifier';
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type {MorphTypeKind, IMorphType} from '$lib/dotnet-types';
2+
3+
import {type ProjectContext, useProjectContext} from '$project/project-context.svelte';
4+
import {type ResourceReturn} from 'runed';
5+
6+
const morphTypesSymbol = Symbol.for('fw-lite-morph-types');
7+
export function useMorphTypesService(): MorphTypesService {
8+
const projectContext = useProjectContext();
9+
return projectContext.getOrAdd(morphTypesSymbol, () => {
10+
return new MorphTypesService(projectContext);
11+
});
12+
}
13+
14+
export class MorphTypesService {
15+
constructor(projectContext: ProjectContext) {
16+
this.#morphTypesResource = projectContext.apiResource([], api => api.getMorphTypes());
17+
}
18+
19+
#morphTypesResource: ResourceReturn<IMorphType[], unknown, true>;
20+
21+
current: IMorphType[] = $derived.by(() => {
22+
return this.#morphTypesResource.current;
23+
});
24+
25+
async refetch() {
26+
await this.#morphTypesResource.refetch();
27+
return this.current;
28+
}
29+
30+
prefixes = $derived.by(() => {
31+
const result: Partial<{[kind in MorphTypeKind]: string|undefined}> = {};
32+
this.current.forEach(morphType => {
33+
result[morphType.kind] = morphType.prefix;
34+
});
35+
return result;
36+
});
37+
38+
suffixes = $derived.by(() => {
39+
const result: Partial<{[kind in MorphTypeKind]: string|undefined}> = {};
40+
this.current.forEach(morphType => {
41+
result[morphType.kind] = morphType.postfix;
42+
});
43+
return result;
44+
});
45+
46+
getPrefix(kind: MorphTypeKind): string|undefined {
47+
return this.prefixes[kind];
48+
}
49+
50+
getSuffix(kind: MorphTypeKind): string|undefined {
51+
return this.suffixes[kind];
52+
}
53+
54+
decorate(headword: string | undefined, kind: MorphTypeKind): string|undefined {
55+
if (!headword) return headword;
56+
const prefix = this.getPrefix(kind) ?? '';
57+
const suffix = this.getSuffix(kind) ?? '';
58+
return `${prefix}${headword}${suffix}`;
59+
}
60+
}

frontend/viewer/src/project/data/writing-system-service.svelte.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {type ProjectContext, useProjectContext} from '$project/project-context.s
1414
import {type ResourceReturn} from 'runed';
1515
import type {View} from '$lib/views/view-data';
1616
import type {ReadonlyDeep} from 'type-fest';
17+
import {type MorphTypesService, useMorphTypesService} from './morph-types.svelte';
1718

1819
export type WritingSystemSelection =
1920
| 'vernacular'
@@ -27,7 +28,8 @@ export type WritingSystemSelection =
2728
const symbol = Symbol.for('fw-lite-ws-service');
2829
export function useWritingSystemService(): WritingSystemService {
2930
const projectContext = useProjectContext();
30-
return projectContext.getOrAdd(symbol, () => new WritingSystemService(projectContext));
31+
const morphTypesService = useMorphTypesService();
32+
return projectContext.getOrAdd(symbol, () => new WritingSystemService(projectContext, morphTypesService));
3133
}
3234

3335
export class WritingSystemService {
@@ -38,7 +40,10 @@ export class WritingSystemService {
3840
return this.#wsResource.current;
3941
}
4042

41-
constructor(projectContext: ProjectContext) {
43+
#morphTypesService: MorphTypesService;
44+
45+
constructor(projectContext: ProjectContext, morphTypesService: MorphTypesService) {
46+
this.#morphTypesService = morphTypesService;
4247
this.#wsResource = projectContext.apiResource({analysis: [], vernacular: []}, async api => {
4348
const result = await api.getWritingSystems();
4449
return {
@@ -121,10 +126,14 @@ export class WritingSystemService {
121126

122127
headword(entry: ReadonlyDeep<IEntry>, ws?: string): string {
123128
if (ws) {
124-
return headword(entry, ws) || '';
129+
return this.#decorated(entry, ws) || '';
125130
}
131+
return firstTruthy(this.vernacularNoAudio, ws => this.#decorated(entry, ws.wsId)) || '';
132+
}
126133

127-
return firstTruthy(this.vernacularNoAudio, ws => headword(entry, ws.wsId)) || '';
134+
#decorated(entry: ReadonlyDeep<IEntry>, ws: string): string | undefined {
135+
// Citation forms should not be decorated with prefix/postfix tokens, only lexeme forms get decorated
136+
return entry.citationForm[ws] || this.#morphTypesService.decorate(entry.lexemeForm[ws], entry.morphType);
128137
}
129138

130139
pickBestAlternative(value: IMultiString, wss: 'vernacular' | 'analysis'): string
@@ -206,10 +215,6 @@ type WritingSystemColors = {
206215
analysis: Record<string, typeof analysisColors[number]>;
207216
}
208217

209-
function headword(entry: ReadonlyDeep<IEntry>, ws: string): string | undefined {
210-
return entry.citationForm[ws] || entry.lexemeForm[ws];
211-
}
212-
213218
function calcWritingSystemColors(writingSystems: IWritingSystems): WritingSystemColors {
214219
const wsColors = {
215220
vernacular: {} as Record<string, typeof vernacularColors[number]>,

frontend/viewer/src/project/demo/demo-entry-data.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {type IEntry, type IWritingSystems, MorphTypeKind, WritingSystemType} from '$lib/dotnet-types';
1+
import {type IEntry, type IMorphType, type IWritingSystems, MorphTypeKind, WritingSystemType} from '$lib/dotnet-types';
22

33
export const projectName = 'Sena 3';
44

@@ -30,6 +30,35 @@ export const partsOfSpeech = [
3030

3131
];
3232

33+
export const morphTypes: IMorphType[] = [
34+
{
35+
id: 'd7f713e8-e8cf-11d3-9764-00c04f186933',
36+
kind: MorphTypeKind.Stem,
37+
name: {en: 'stem' },
38+
abbreviation: {en: 'ubd stem' },
39+
description: {},
40+
secondaryOrder: 0,
41+
},
42+
{
43+
id: 'd7f713db-e8cf-11d3-9764-00c04f186933',
44+
kind: MorphTypeKind.Prefix,
45+
name: {en: 'prefix' },
46+
abbreviation: {en: 'pfx' },
47+
description: {},
48+
postfix: '-',
49+
secondaryOrder: 20,
50+
},
51+
{
52+
id: 'd7f713dd-e8cf-11d3-9764-00c04f186933',
53+
kind: MorphTypeKind.Suffix,
54+
name: {en: 'suffix' },
55+
abbreviation: {en: 'sfx' },
56+
description: {},
57+
prefix: '-',
58+
secondaryOrder: 70,
59+
},
60+
];
61+
3362
export const writingSystems: IWritingSystems = {
3463
'analysis': [
3564
{

frontend/viewer/src/project/demo/in-memory-demo-api.ts

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
type IIndexQueryOptions,
1111
type IMiniLcmFeatures,
1212
type IMiniLcmJsInvokable,
13+
type IMorphType,
1314
type IPartOfSpeech,
1415
type IProjectModel,
1516
type IPublication,
@@ -22,8 +23,9 @@ import {
2223
type WritingSystemType,
2324
type ICustomView,
2425
ViewBase,
26+
MorphTypeKind,
2527
} from '$lib/dotnet-types';
26-
import {entries, partsOfSpeech, projectName, writingSystems} from './demo-entry-data';
28+
import {entries, morphTypes, partsOfSpeech, projectName, writingSystems} from './demo-entry-data';
2729

2830
import {WritingSystemService} from '../data/writing-system-service.svelte';
2931
import {FwLitePlatform} from '$lib/dotnet-types/generated-types/FwLiteShared/FwLitePlatform';
@@ -41,6 +43,7 @@ import {type IAvailableUpdate, UpdateResult} from '$lib/dotnet-types/generated-t
4143
import {type EventBus, useEventBus, ProjectEventBus} from '$lib/services/event-bus';
4244
import type {IJsEventListener} from '$lib/dotnet-types/generated-types/FwLiteShared/Events/IJsEventListener';
4345
import {initProjectStorage} from '$lib/storage';
46+
import {MorphTypesService} from '$project/data/morph-types.svelte';
4447

4548
function pickWs(ws: string, defaultWs: string): string {
4649
return ws === 'default' ? defaultWs : ws;
@@ -50,17 +53,6 @@ const complexFormTypes = entries
5053
.flatMap(entry => entry.complexFormTypes)
5154
.filter((value, index, all) => all.findIndex(v2 => v2.id === value.id) === index);
5255

53-
function filterEntries(entries: IEntry[], query: string): IEntry[] {
54-
return entries.filter(entry =>
55-
[
56-
...Object.values(entry.lexemeForm ?? {}),
57-
...Object.values(entry.citationForm ?? {}),
58-
...entry.senses.flatMap(sense => [
59-
...Object.values(sense.gloss ?? {}),
60-
]),
61-
].some(value => value?.toLowerCase().includes(query.toLowerCase())));
62-
}
63-
6456
export const mockFwLiteConfig: IFwLiteConfig = {
6557
appVersion: 'dev',
6658
feedbackUrl: '',
@@ -94,10 +86,12 @@ const mockJsEventListener: IJsEventListener = {
9486
};
9587

9688
export class InMemoryDemoApi implements IMiniLcmJsInvokable {
89+
#morphTypesService: MorphTypesService;
9790
#writingSystemService: WritingSystemService;
9891
#projectEventBus: ProjectEventBus;
9992
constructor(projectContext: ProjectContext, eventBus: EventBus) {
100-
this.#writingSystemService = new WritingSystemService(projectContext);
93+
this.#morphTypesService = new MorphTypesService(projectContext);
94+
this.#writingSystemService = new WritingSystemService(projectContext, this.#morphTypesService);
10195
this.#projectEventBus = new ProjectEventBus(projectContext, eventBus);
10296
}
10397

@@ -159,6 +153,17 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable {
159153
);
160154
}
161155

156+
getMorphTypes(): Promise<IMorphType[]> {
157+
return Promise.resolve(
158+
morphTypes
159+
// [
160+
// {id: 'd7f713e8-e8cf-11d3-9764-00c04f186933', kind: MorphTypeKind.Stem},
161+
// {id: 'd7f713db-e8cf-11d3-9764-00c04f186933', kind: MorphTypeKind.Prefix, postfix='-'},
162+
// {id: 'd7f713dd-e8cf-11d3-9764-00c04f186933', kind: MorphTypeKind.Suffix, prefix='-'},
163+
// ]
164+
);
165+
}
166+
162167
getPartsOfSpeech(): Promise<IPartOfSpeech[]> {
163168
return Promise.resolve(
164169
partsOfSpeech
@@ -250,27 +255,44 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable {
250255
return entries.slice(options.offset, options.offset + options.count);
251256
}
252257

258+
private filterEntries(entries: IEntry[], query: string): IEntry[] {
259+
return entries.filter(entry =>
260+
[
261+
...this.#writingSystemService.vernacular.map(ws => this.#writingSystemService.headword(entry, ws.wsId)),
262+
...Object.values(entry.lexemeForm ?? {}),
263+
...entry.senses.flatMap(sense => [
264+
...Object.values(sense.gloss ?? {}),
265+
]),
266+
].some(value => value?.toLowerCase().includes(query.toLowerCase())));
267+
}
268+
253269
private getFilteredSortedEntries(query?: string, options?: Omit<IQueryOptions, 'count' | 'offset'>): IEntry[] {
254270
const entries = this.getFilteredEntries(query, options);
255-
256-
if (!options) return entries;
257271
const defaultWs = writingSystems.vernacular[0].wsId;
258-
const sortWs = pickWs(options.order.writingSystem, defaultWs);
272+
const sortWs = pickWs(options?.order?.writingSystem ?? defaultWs, defaultWs);
273+
const ascending = options?.order?.ascending ?? true;
274+
const stem = morphTypes.find(m => m.kind === MorphTypeKind.Stem)!;
259275
return entries
260276
.sort((e1, e2) => {
261-
const v1 = this.#writingSystemService.headword(e1, sortWs);
262-
const v2 = this.#writingSystemService.headword(e2, sortWs);
277+
// morph-tokens should not be included when sorting
278+
const v1 = e1.citationForm[sortWs] || e1.lexemeForm[sortWs];
279+
const v2 = e2.citationForm[sortWs] || e2.lexemeForm[sortWs];
263280
if (!v2) return -1;
264281
if (!v1) return 1;
265282
let compare = v1.localeCompare(v2, sortWs);
266-
if (compare == 0) compare = e1.id.localeCompare(e2.id);
267-
return options.order.ascending ? compare : -compare;
283+
if (compare === 0) {
284+
const m1 = (morphTypes.find(m => m.kind === e1.morphType) ?? stem);
285+
const m2 = (morphTypes.find(m => m.kind === e2.morphType) ?? stem);
286+
compare = m1.secondaryOrder - m2.secondaryOrder;
287+
}
288+
if (compare === 0) compare = e1.id.localeCompare(e2.id);
289+
return ascending ? compare : -compare;
268290
});
269291
}
270292

271293
private getFilteredEntries(query?: string, options?: IFilterQueryOptions): IEntry[] {
272294
let entries = this._Entries();
273-
if (query) entries = filterEntries(entries, query);
295+
if (query) entries = this.filterEntries(entries, query);
274296
if (!options) return entries;
275297

276298
const defaultWs = writingSystems.vernacular[0].wsId;

frontend/viewer/tests/entries-list.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,12 +160,12 @@ test.describe('EntriesList', () => {
160160
expect(firstEntryText).toBeTruthy();
161161

162162
// Update first entry by prepending to its headword (so it stays at index 0)
163-
const {updatedHeadword} = await api.updateEntryHeadwordPrepend(0, '-UPDATED-');
163+
const {updatedHeadword} = await api.updateEntryHeadwordPrepend(0, '---UPDATED---');
164164

165165
// The first entry in UI should now show the updated text
166166
await expect(async () => {
167167
const newFirstEntryText = await entriesList.entryRows.first().textContent();
168-
expect(newFirstEntryText).toContain('-UPDATED-');
168+
expect(newFirstEntryText).toContain('---UPDATED---');
169169
}).toPass({timeout: 5000});
170170

171171
await expect(entriesList.entryWithText(updatedHeadword)).toBeVisible();

0 commit comments

Comments
 (0)