Skip to content
Merged
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
6 changes: 6 additions & 0 deletions backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ public ValueTask<ComplexFormType[]> GetComplexFormTypes()
return _wrappedApi.GetComplexFormType(id);
}

[JSInvokable]
public ValueTask<MorphType[]> GetMorphTypes()
{
return _wrappedApi.GetMorphTypes().ToArrayAsync();
}

[JSInvokable]
public Task<int> CountEntries(string? query, FilterQueryOptions? options)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -31,6 +32,7 @@ export interface IMiniLcmJsInvokable
getSemanticDomains() : Promise<ISemanticDomain[]>;
getComplexFormTypes() : Promise<IComplexFormType[]>;
getComplexFormType(id: string) : Promise<IComplexFormType | null>;
getMorphTypes() : Promise<IMorphType[]>;
countEntries(query?: string, options?: IFilterQueryOptions) : Promise<number>;
getEntryIndex(id: string, query?: string, options?: IIndexQueryOptions) : Promise<number>;
getEntries(options?: IQueryOptions) : Promise<IEntry[]>;
Expand Down
1 change: 1 addition & 0 deletions frontend/viewer/src/lib/dotnet-types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
60 changes: 60 additions & 0 deletions frontend/viewer/src/project/data/morph-types.svelte.ts
Original file line number Diff line number Diff line change
@@ -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<IMorphType[], unknown, true>;

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}`;
}
}
21 changes: 13 additions & 8 deletions frontend/viewer/src/project/data/writing-system-service.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -122,10 +127,14 @@ export class WritingSystemService {

headword(entry: ReadonlyDeep<IEntry>, ws?: string): string {
if (ws) {
return headword(entry, ws) || '';
return this.#decorated(entry, ws) || '';
}
return firstTruthy(this.vernacularNoAudio, ws => this.#decorated(entry, ws.wsId)) || '';
}

return firstTruthy(this.vernacularNoAudio, ws => headword(entry, ws.wsId)) || '';
#decorated(entry: ReadonlyDeep<IEntry>, 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);
}
Comment on lines 128 to 138
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make the decorated headword searchable too.

After Line 137, the UI can show -a/a-, but the current demo search/filter path still matches only raw lexemeForm/citationForm values. That means users can now see headwords they cannot filter for, which is already the failing -a case called out in the PR notes. Please drive filtering from the same decorated headword representation.

Copy link
Copy Markdown
Collaborator

@myieye myieye Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I fixed sorting in the demo API to not consider/include/respect morph-tokens.

TODO: It's true that the filter code should check against headword OR lexeme.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

Let me look at the filter code to provide concrete guidance on where and how to fix it.


🏁 Script executed:

#!/bin/bash
# Find filter/search related code in the frontend viewer
rg -n "lexemeForm\|citationForm\|headword" --type=ts --type=svelte -C3 frontend/viewer/src \
  -g '!*.d.ts' \
  | grep -i "filter\|search\|match\|find" -A3 -B3 | head -100

# Also search for the filter function directly
rg -nP "filter|search" --type=ts -l frontend/viewer/src/project

Length of output: 151


🏁 Script executed:

#!/bin/bash
# Find filter/search logic - look for where entries are filtered by text input
rg -n "filter\|search\|match" frontend/viewer/src/project/demo/in-memory-demo-api.ts -C5

# Look for entry filtering/searching code across all files
rg -rn "lexemeForm\|citationForm\|headword" frontend/viewer/src/project/ --include="*.ts" -C3 | grep -i "filter\|search\|match\|includes\|toLowerCase" -A3 -B3 | head -120

# Also search for entry list / filter entry functions
rg -rn "filterEntries\|searchEntries\|getEntries\|filter.*entry\|entry.*filter" frontend/viewer/src/ --include="*.ts" -C5 | head -120

Length of output: 883


🏁 Script executed:

#!/bin/bash
# Find filter/search logic in the demo API
rg -n "filter|search|match" frontend/viewer/src/project/demo/in-memory-demo-api.ts -C5

# Find getEntries or similar entry-listing methods with search/filter params
rg -rn "getEntries" frontend/viewer/src/project/ -C5

# Find where entries are filtered by text (lexemeForm, citationForm, headword)
rg -rn "lexemeForm|citationForm" frontend/viewer/src/project/demo/in-memory-demo-api.ts -C5

Length of output: 4097


@myieye, thanks for the sorting fix and the TODO note. The filterEntries standalone function in in-memory-demo-api.ts is the concrete place to fix — it already checks raw lexemeForm / citationForm, but needs to also include the decorated values. Since InMemoryDemoApi already holds #morphTypesService, the change is small:

// Pass morphTypesService into filterEntries
function filterEntries(entries: IEntry[], query: string, morphTypesService: MorphTypesService): IEntry[] {
  return entries.filter(entry => {
    const decoratedHeadwords = Object.keys(entry.lexemeForm ?? {}).map(ws =>
      entry.citationForm[ws]  // citation form is never decorated (matches `#decorated` logic)
        ? undefined
        : morphTypesService.decorate(entry.lexemeForm[ws], entry.morphType)
    );
    return [
      ...Object.values(entry.lexemeForm ?? {}),
      ...Object.values(entry.citationForm ?? {}),
      ...decoratedHeadwords,
      ...entry.senses.flatMap(sense => [...Object.values(sense.gloss ?? {})]),
    ].some(value => value?.toLowerCase().includes(query.toLowerCase()));
  });
}

Then at the call site in getFilteredEntries:

if (query) entries = filterEntries(entries, query, this.#morphTypesService);

The citationForm guard mirrors the same logic already in WritingSystemService.#decorated, so the two stay in sync — if the entry has a citation form for a given WS, the filter won't add a phantom decorated hit for that WS. The exemplar filter is a separate concern (index-tab navigation), but the same pattern would apply there if you want consistency.


🧠 Learnings used
Learnt from: CR
Repo: sillsdev/languageforge-lexbox PR: 0
File: frontend/viewer/AGENTS.md:0-0
Timestamp: 2026-02-04T14:30:07.371Z
Learning: Applies to frontend/viewer/**/*.{ts,tsx,svelte} : Use TypeScript types and API interfaces generated from .NET via Reinforced.Typings - must rebuild backend (dotnet build backend/FwLite/FwLiteShared/FwLiteShared.csproj) when .NET models or JSInvokable APIs change


pickBestAlternative(value: IMultiString, wss: 'vernacular' | 'analysis'): string
Expand Down Expand Up @@ -207,10 +216,6 @@ type WritingSystemColors = {
analysis: Record<string, typeof analysisColors[number]>;
}

function headword(entry: ReadonlyDeep<IEntry>, ws: string): string | undefined {
return entry.citationForm[ws] || entry.lexemeForm[ws];
}

function calcWritingSystemColors(writingSystems: IWritingSystems): WritingSystemColors {
const wsColors = {
vernacular: {} as Record<string, typeof vernacularColors[number]>,
Expand Down
31 changes: 30 additions & 1 deletion frontend/viewer/src/project/demo/demo-entry-data.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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': [
{
Expand Down
24 changes: 20 additions & 4 deletions frontend/viewer/src/project/demo/in-memory-demo-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type IFilterQueryOptions,
type IIndexQueryOptions,
type IMiniLcmJsInvokable,
type IMorphType,
type IPartOfSpeech,
type IProjectModel,
type IPublication,
Expand All @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -154,6 +158,17 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable {
);
}

getMorphTypes(): Promise<IMorphType[]> {
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<IPartOfSpeech[]> {
return Promise.resolve(
partsOfSpeech
Expand Down Expand Up @@ -224,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);
Expand Down
4 changes: 2 additions & 2 deletions frontend/viewer/tests/entries-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading