diff --git a/__mocks__/papi-frontend.ts b/__mocks__/papi-frontend.ts
index c72659c7..33895206 100644
--- a/__mocks__/papi-frontend.ts
+++ b/__mocks__/papi-frontend.ts
@@ -13,6 +13,8 @@ const mockLogger = {
const mockSendCommand = jest.fn();
const mockNotificationsSend = jest.fn();
const mockProjectDataProvidersGet = jest.fn();
+const mockNetworkObjectsGet = jest.fn();
+const mockWaitForNetworkObject = jest.fn();
const papi = {
commands: {
@@ -27,6 +29,12 @@ const papi = {
projectDataProviders: {
get: mockProjectDataProvidersGet,
},
+ networkObjects: {
+ get: mockNetworkObjectsGet,
+ },
+ networkObjectStatus: {
+ waitForNetworkObject: mockWaitForNetworkObject,
+ },
};
module.exports = {
diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json
index f3d281b7..c9355017 100644
--- a/contributions/localizedStrings.json
+++ b/contributions/localizedStrings.json
@@ -74,6 +74,9 @@
"%interlinearizer_viewOption_freeScrollStrip%": "Scroll strip freely",
"%interlinearizer_projectSettings_freeScrollStrip%": "Scroll Strip Freely",
"%interlinearizer_projectSettings_freeScrollStripDescription%": "Scroll the continuous strip with the mouse wheel instead of stepping the focus one phrase at a time",
+ "%interlinearizer_projectSettings_lexiconAuthority%": "Lexicon Software",
+ "%interlinearizer_projectSettings_lexiconCode%": "Lexicon",
+ "%interlinearizer_projectSettings_lexiconCodeDescription%": "The lexicon this project's glosses are linked to. Clear it to stop using that lexicon; glosses already linked to it keep the wording they were saved with",
"%interlinearizer_viewOption_showSuggestions%": "Show suggestions",
"%interlinearizer_glossInput_placeholder%": "gloss",
"%interlinearizer_freeTranslationInput_placeholder%": "Free translation",
diff --git a/contributions/projectSettings.json b/contributions/projectSettings.json
index 2a5469d9..9b0524dd 100644
--- a/contributions/projectSettings.json
+++ b/contributions/projectSettings.json
@@ -36,6 +36,16 @@
"label": "%interlinearizer_projectSettings_freeScrollStrip%",
"description": "%interlinearizer_projectSettings_freeScrollStripDescription%",
"default": false
+ },
+ "interlinearizer.lexiconAuthority": {
+ "label": "%interlinearizer_projectSettings_lexiconAuthority%",
+ "default": "",
+ "isHidden": true
+ },
+ "interlinearizer.lexiconCode": {
+ "label": "%interlinearizer_projectSettings_lexiconCode%",
+ "description": "%interlinearizer_projectSettings_lexiconCodeDescription%",
+ "default": ""
}
}
}
diff --git a/src/__tests__/hooks/useLexiconRegistry.test.ts b/src/__tests__/hooks/useLexiconRegistry.test.ts
index 9f6cf90f..f9ecdc28 100644
--- a/src/__tests__/hooks/useLexiconRegistry.test.ts
+++ b/src/__tests__/hooks/useLexiconRegistry.test.ts
@@ -1,35 +1,132 @@
///
-import { renderHook } from '@testing-library/react';
+import { renderHook, waitFor } from '@testing-library/react';
+import { useProjectSetting } from '@papi/frontend/react';
import useLexiconRegistry from '../../hooks/useLexiconRegistry';
+import { fwLiteLexiconProvider } from '../../utils/fw-lite-lexicon';
+import { FW_LITE_AUTHORITY } from '../../utils/lexicon-authorities';
+
+jest.mock('../../utils/fw-lite-lexicon', () => ({
+ fwLiteLexiconProvider: {
+ authority: 'fw-lite',
+ isAvailable: jest.fn(),
+ connect: jest.fn(),
+ },
+}));
+
+const provider = jest.mocked(fwLiteLexiconProvider);
+const mockUseProjectSetting = jest.mocked(useProjectSetting);
+
+/** Serves the stored link, keyed by setting so the two halves can disagree. */
+function storeLink(authority?: string, lexiconCode?: string) {
+ mockUseProjectSetting.mockImplementation((_projectId, key) => [
+ key === 'interlinearizer.lexiconAuthority' ? authority : lexiconCode,
+ jest.fn(),
+ jest.fn(),
+ false,
+ ]);
+}
+
+/** A resolver that answers for FieldWorks Lite and can be searched only when given a lexicon. */
+function stubResolver(lexiconId?: string) {
+ return {
+ authorities: [FW_LITE_AUTHORITY],
+ capabilities: {
+ search: !!lexiconId,
+ create: !!lexiconId,
+ allomorphs: false,
+ msas: false,
+ },
+ resolveSense: jest.fn(async () => undefined),
+ searchByForm: jest.fn(async () => []),
+ createEntry: jest.fn(async () => {
+ throw new Error('unused');
+ }),
+ };
+}
+
+beforeEach(() => {
+ provider.connect.mockImplementation(stubResolver);
+ provider.isAvailable.mockResolvedValue(true);
+ storeLink('fw-lite', 'lex-1');
+});
describe('useLexiconRegistry', () => {
- it('offers no lexicon capability while no lexicon is connected', () => {
- const { result } = renderHook(() => useLexiconRegistry());
+ it('holds no lexicon on the first render, so a consumer never waits on one', () => {
+ const { result } = renderHook(() => useLexiconRegistry('project-1'));
+
+ expect(result.current.resolverWith('search')).toBeUndefined();
+ expect(result.current.isForeign({ authority: FW_LITE_AUTHORITY })).toBe(true);
+ });
+
+ it('connects the linked lexicon once the software has answered', async () => {
+ const { result } = renderHook(() => useLexiconRegistry('project-1'));
+
+ await waitFor(() => expect(result.current.resolverWith('search')).toBeDefined());
+ expect(provider.connect).toHaveBeenCalledWith('lex-1');
+ });
+
+ it('reads a ref of unreachable software as foreign', async () => {
+ provider.isAvailable.mockResolvedValue(false);
+ const { result } = renderHook(() => useLexiconRegistry('project-1'));
+
+ await waitFor(() => expect(provider.isAvailable).toHaveBeenCalled());
+ expect(result.current.isForeign({ authority: FW_LITE_AUTHORITY })).toBe(true);
+ });
+
+ it('reads a ref of reachable but unlinked software as native, so it renders as a miss', async () => {
+ storeLink('', '');
+
+ const { result } = renderHook(() => useLexiconRegistry('project-1'));
+
+ await waitFor(() =>
+ expect(result.current.isForeign({ authority: FW_LITE_AUTHORITY })).toBe(false),
+ );
expect(result.current.resolverWith('search')).toBeUndefined();
});
- it('treats every ref as foreign while no lexicon is connected', () => {
- const { result } = renderHook(() => useLexiconRegistry());
+ it.each([
+ ['an authority without a lexicon code', 'fw-lite', ''],
+ ['a lexicon code without an authority', '', 'lex-1'],
+ ])('treats %s as no link', async (_case, authority, lexiconCode) => {
+ storeLink(authority, lexiconCode);
+
+ const { result } = renderHook(() => useLexiconRegistry('project-1'));
- expect(result.current.isForeign({ authority: 'some-lexicon' })).toBe(true);
+ await waitFor(() => expect(provider.connect).toHaveBeenCalled());
+ expect(provider.connect).toHaveBeenLastCalledWith(undefined);
+ expect(result.current.resolverWith('search')).toBeUndefined();
});
- it('resolves no sense while no lexicon is connected', async () => {
- const { result } = renderHook(() => useLexiconRegistry());
+ it('treats a setting the platform could not read as unset', async () => {
+ storeLink(undefined, undefined);
- await expect(
- result.current.resolveSense({ authority: 'some-lexicon', senseId: 's-1' }),
- ).resolves.toBeUndefined();
+ const { result } = renderHook(() => useLexiconRegistry('project-1'));
+
+ await waitFor(() => expect(provider.connect).toHaveBeenCalled());
+ expect(result.current.resolverWith('search')).toBeUndefined();
});
- it('hands back one registry, so a consumer can hold on to it', () => {
- const { result, rerender } = renderHook(() => useLexiconRegistry());
- const first = result.current;
+ it('hands back one registry across renders, so a consumer can hold on to it', async () => {
+ const { result, rerender } = renderHook(() => useLexiconRegistry('project-1'));
+ await waitFor(() => expect(result.current.resolverWith('search')).toBeDefined());
+ const settled = result.current;
rerender();
- expect(result.current).toBe(first);
+ expect(result.current).toBe(settled);
+ });
+
+ it('answers for the project in view, so a second project gets its own link', async () => {
+ const { result, rerender } = renderHook(({ projectId }) => useLexiconRegistry(projectId), {
+ initialProps: { projectId: 'project-1' },
+ });
+ await waitFor(() => expect(result.current.resolverWith('search')).toBeDefined());
+
+ storeLink('fw-lite', 'lex-2');
+ rerender({ projectId: 'project-2' });
+
+ await waitFor(() => expect(provider.connect).toHaveBeenLastCalledWith('lex-2'));
});
});
diff --git a/src/__tests__/test-helpers.ts b/src/__tests__/test-helpers.ts
index ef8ad10d..b19f7ce7 100644
--- a/src/__tests__/test-helpers.ts
+++ b/src/__tests__/test-helpers.ts
@@ -389,3 +389,41 @@ export function getMockedPdpGet(papiModule: unknown): jest.Mock {
}
throw new Error('Expected the mocked @papi/frontend projectDataProviders.get');
}
+
+/**
+ * Reaches one jest fn inside the papi-frontend mock by walking `path`, so a mock that moves or
+ * disappears fails loudly here rather than as a puzzling `undefined` in a test.
+ *
+ * @param path - Property names from the module down to the mock.
+ * @throws When the path does not lead to a jest fn.
+ */
+function getMockedPapiFn(papiModule: unknown, path: readonly string[]): jest.Mock {
+ const target = path.reduce(
+ (current, key) =>
+ !!current && typeof current === 'object' ? Reflect.get(current, key) : undefined,
+ papiModule,
+ );
+ if (jest.isMockFunction(target)) return target;
+ throw new Error(`Expected the mocked @papi/frontend ${path.join('.')}`);
+}
+
+/**
+ * Returns the papi-frontend mock's `networkObjects.get` as the raw jest fn, so tests can resolve
+ * partial network objects (only the methods under test) without type assertions against the full
+ * service interface.
+ *
+ * @throws When the module is not the jest papi-frontend mock.
+ */
+export function getMockedNetworkObjectGet(papiModule: unknown): jest.Mock {
+ return getMockedPapiFn(papiModule, ['networkObjects', 'get']);
+}
+
+/**
+ * Returns the papi-frontend mock's `networkObjectStatus.waitForNetworkObject` as the raw jest fn,
+ * so a test can decide whether a network object ever registers.
+ *
+ * @throws When the module is not the jest papi-frontend mock.
+ */
+export function getMockedWaitForNetworkObject(papiModule: unknown): jest.Mock {
+ return getMockedPapiFn(papiModule, ['networkObjectStatus', 'waitForNetworkObject']);
+}
diff --git a/src/__tests__/utils/fw-lite-lexicon.test.ts b/src/__tests__/utils/fw-lite-lexicon.test.ts
new file mode 100644
index 00000000..b97cff95
--- /dev/null
+++ b/src/__tests__/utils/fw-lite-lexicon.test.ts
@@ -0,0 +1,317 @@
+///
+
+import papi from '@papi/frontend';
+import type { SenseRef } from 'interlinearizer';
+import type { LexiconEntry } from '../../types/lexicon-extension';
+import { fwLiteLexiconProvider, resetEntryServiceForTesting } from '../../utils/fw-lite-lexicon';
+import { FW_LITE_AUTHORITY } from '../../utils/lexicon-authorities';
+import { getMockedNetworkObjectGet, getMockedWaitForNetworkObject } from '../test-helpers';
+
+const LEXICON = 'my-lexicon';
+
+const mockNetworkObjectGet = getMockedNetworkObjectGet(papi);
+const mockWaitForNetworkObject = getMockedWaitForNetworkObject(papi);
+
+/** The subset of the entry service a test drives, with every call observable. */
+function stubService(
+ overrides: Partial>,
+) {
+ return {
+ getSense: jest.fn(async () => undefined),
+ getEntries: jest.fn(async () => undefined),
+ addEntry: jest.fn(async () => undefined),
+ ...overrides,
+ };
+}
+
+/** Registers `service` as the lexicon entry service the provider will find. */
+function serve(service: object) {
+ mockWaitForNetworkObject.mockResolvedValue({ id: 'lexicon.entryService' });
+ mockNetworkObjectGet.mockResolvedValue(service);
+}
+
+/** Leaves nothing registered, as when the Lexicon extension is not installed. */
+function serveNothing() {
+ mockWaitForNetworkObject.mockRejectedValue(new Error('timed out'));
+}
+
+function entry(overrides?: Partial): LexiconEntry {
+ return {
+ id: 'e-1',
+ lexemeForm: { hbo: 'mayim' },
+ senses: [{ id: 's-1', gloss: { en: 'water' } }],
+ ...overrides,
+ };
+}
+
+function senseRef(projectId?: string): SenseRef {
+ return { authority: FW_LITE_AUTHORITY, projectId, senseId: 's-1' };
+}
+
+beforeEach(() => {
+ resetEntryServiceForTesting();
+});
+
+describe('fwLiteLexiconProvider', () => {
+ it('declares the FieldWorks Lite id space', () => {
+ expect(fwLiteLexiconProvider.authority).toBe(FW_LITE_AUTHORITY);
+ });
+
+ describe('isAvailable', () => {
+ it('is available once the lexicon service is registered', async () => {
+ serve(stubService({}));
+
+ await expect(fwLiteLexiconProvider.isAvailable()).resolves.toBe(true);
+ });
+
+ it('is unavailable when nothing registers the service in time', async () => {
+ serveNothing();
+
+ await expect(fwLiteLexiconProvider.isAvailable()).resolves.toBe(false);
+ });
+
+ it('is unavailable when the service is announced but cannot be fetched', async () => {
+ mockWaitForNetworkObject.mockResolvedValue({ id: 'x' });
+ mockNetworkObjectGet.mockResolvedValue(undefined);
+
+ await expect(fwLiteLexiconProvider.isAvailable()).resolves.toBe(false);
+ });
+
+ it('waits for the service once, so a second lexicon action does not pay the wait again', async () => {
+ serve(stubService({}));
+
+ await fwLiteLexiconProvider.isAvailable();
+ await fwLiteLexiconProvider.isAvailable();
+
+ expect(mockWaitForNetworkObject).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('connected to no lexicon', () => {
+ it('still declares the authority, so a ref FieldWorks Lite minted is not foreign', () => {
+ expect(fwLiteLexiconProvider.connect().authorities).toEqual([FW_LITE_AUTHORITY]);
+ });
+
+ it('offers no capability, so nothing invites use of a lexicon that is not linked', () => {
+ expect(fwLiteLexiconProvider.connect().capabilities).toEqual({
+ search: false,
+ create: false,
+ allomorphs: false,
+ msas: false,
+ });
+ });
+
+ it('resolves no sense', async () => {
+ await expect(
+ fwLiteLexiconProvider.connect().resolveSense(senseRef()),
+ ).resolves.toBeUndefined();
+ });
+
+ it('finds nothing to gloss a form with', async () => {
+ await expect(fwLiteLexiconProvider.connect().searchByForm('mayim')).resolves.toEqual([]);
+ });
+
+ it('refuses to create an entry rather than reporting one it did not create', async () => {
+ await expect(
+ fwLiteLexiconProvider.connect().createEntry({ form: 'mayim', writingSystem: 'hbo' }),
+ ).rejects.toThrow('No lexicon is connected');
+ });
+ });
+
+ describe('connected to a lexicon', () => {
+ it('can be searched and added to, and holds no allomorphs or analyses', () => {
+ expect(fwLiteLexiconProvider.connect(LEXICON).capabilities).toEqual({
+ search: true,
+ create: true,
+ allomorphs: false,
+ msas: false,
+ });
+ });
+
+ describe('resolveSense', () => {
+ it('resolves a sense of the connected lexicon to its gloss', async () => {
+ const service = stubService({
+ getSense: jest.fn(async () => ({ id: 's-1', gloss: { en: 'water' } })),
+ });
+ serve(service);
+
+ await expect(
+ fwLiteLexiconProvider.connect(LEXICON).resolveSense(senseRef(LEXICON)),
+ ).resolves.toEqual({ gloss: { en: 'water' } });
+ expect(service.getSense).toHaveBeenCalledWith(LEXICON, 's-1');
+ });
+
+ it('misses a ref naming another lexicon, and never asks that lexicon for it', async () => {
+ const service = stubService({});
+ serve(service);
+
+ await expect(
+ fwLiteLexiconProvider.connect(LEXICON).resolveSense(senseRef('other-lexicon')),
+ ).resolves.toBeUndefined();
+ expect(service.getSense).not.toHaveBeenCalled();
+ });
+
+ it('misses a ref that names no lexicon, rather than taking the connected one as meant', async () => {
+ const service = stubService({});
+ serve(service);
+
+ await expect(
+ fwLiteLexiconProvider.connect(LEXICON).resolveSense(senseRef()),
+ ).resolves.toBeUndefined();
+ expect(service.getSense).not.toHaveBeenCalled();
+ });
+
+ it('misses a sense the lexicon does not have', async () => {
+ serve(stubService({}));
+
+ await expect(
+ fwLiteLexiconProvider.connect(LEXICON).resolveSense(senseRef(LEXICON)),
+ ).resolves.toBeUndefined();
+ });
+
+ it('misses while the lexicon is unreachable', async () => {
+ serveNothing();
+
+ await expect(
+ fwLiteLexiconProvider.connect(LEXICON).resolveSense(senseRef(LEXICON)),
+ ).resolves.toBeUndefined();
+ });
+ });
+
+ describe('searchByForm', () => {
+ it('names every sense of every matching entry, alongside the form it is listed under', async () => {
+ const service = stubService({
+ getEntries: jest.fn(async () => [
+ entry({
+ senses: [
+ { id: 's-1', gloss: { en: 'water' } },
+ { id: 's-2', gloss: { en: 'waters' } },
+ ],
+ }),
+ ]),
+ });
+ serve(service);
+
+ await expect(fwLiteLexiconProvider.connect(LEXICON).searchByForm('mayim')).resolves.toEqual(
+ [
+ {
+ gloss: { en: 'water' },
+ lexemeForm: { hbo: 'mayim' },
+ ref: { authority: FW_LITE_AUTHORITY, projectId: LEXICON, senseId: 's-1' },
+ },
+ {
+ gloss: { en: 'waters' },
+ lexemeForm: { hbo: 'mayim' },
+ ref: { authority: FW_LITE_AUTHORITY, projectId: LEXICON, senseId: 's-2' },
+ },
+ ],
+ );
+ expect(service.getEntries).toHaveBeenCalledWith(LEXICON, { surfaceForm: 'mayim' });
+ });
+
+ it('drops an entry holding no form in the writing system asked for', async () => {
+ serve(
+ stubService({
+ getEntries: jest.fn(async () => [
+ entry(),
+ entry({ id: 'e-2', lexemeForm: { el: 'hydor' } }),
+ ]),
+ }),
+ );
+
+ const candidates = await fwLiteLexiconProvider
+ .connect(LEXICON)
+ .searchByForm('mayim', { writingSystem: 'hbo' });
+
+ expect(candidates).toHaveLength(1);
+ expect(candidates[0].lexemeForm).toEqual({ hbo: 'mayim' });
+ });
+
+ it('caps the candidates at the count asked for', async () => {
+ serve(
+ stubService({
+ getEntries: jest.fn(async () => [entry(), entry({ id: 'e-2' })]),
+ }),
+ );
+
+ await expect(
+ fwLiteLexiconProvider.connect(LEXICON).searchByForm('mayim', { limit: 1 }),
+ ).resolves.toHaveLength(1);
+ });
+
+ it('finds nothing when the lexicon cannot be read', async () => {
+ serve(stubService({}));
+
+ await expect(fwLiteLexiconProvider.connect(LEXICON).searchByForm('mayim')).resolves.toEqual(
+ [],
+ );
+ });
+ });
+
+ describe('createEntry', () => {
+ it('creates the entry under one sense, so a gloss has a sense to link to', async () => {
+ const service = stubService({
+ addEntry: jest.fn(async () => entry()),
+ });
+ serve(service);
+
+ await expect(
+ fwLiteLexiconProvider
+ .connect(LEXICON)
+ .createEntry({ form: 'mayim', writingSystem: 'hbo', gloss: { en: 'water' } }),
+ ).resolves.toEqual({
+ entryRef: { authority: FW_LITE_AUTHORITY, projectId: LEXICON, entryId: 'e-1' },
+ senseRef: { authority: FW_LITE_AUTHORITY, projectId: LEXICON, senseId: 's-1' },
+ });
+ expect(service.addEntry).toHaveBeenCalledWith(LEXICON, {
+ lexemeForm: { hbo: 'mayim' },
+ senses: [{ gloss: { en: 'water' } }],
+ });
+ });
+
+ it('creates a sense for an entry drafted without a gloss', async () => {
+ const service = stubService({ addEntry: jest.fn(async () => entry()) });
+ serve(service);
+
+ await fwLiteLexiconProvider
+ .connect(LEXICON)
+ .createEntry({ form: 'mayim', writingSystem: 'hbo' });
+
+ expect(service.addEntry).toHaveBeenCalledWith(LEXICON, {
+ lexemeForm: { hbo: 'mayim' },
+ senses: [{ gloss: {} }],
+ });
+ });
+
+ it('refuses when the lexicon is unreachable', async () => {
+ serveNothing();
+
+ await expect(
+ fwLiteLexiconProvider
+ .connect(LEXICON)
+ .createEntry({ form: 'mayim', writingSystem: 'hbo' }),
+ ).rejects.toThrow('unreachable');
+ });
+
+ it('refuses when the lexicon reports no entry', async () => {
+ serve(stubService({}));
+
+ await expect(
+ fwLiteLexiconProvider
+ .connect(LEXICON)
+ .createEntry({ form: 'mayim', writingSystem: 'hbo' }),
+ ).rejects.toThrow('no entry and sense');
+ });
+
+ it('refuses when the created entry carries no sense a gloss could link to', async () => {
+ serve(stubService({ addEntry: jest.fn(async () => entry({ senses: [] })) }));
+
+ await expect(
+ fwLiteLexiconProvider
+ .connect(LEXICON)
+ .createEntry({ form: 'mayim', writingSystem: 'hbo' }),
+ ).rejects.toThrow('no entry and sense');
+ });
+ });
+ });
+});
diff --git a/src/__tests__/utils/lexicon-resolvers.test.ts b/src/__tests__/utils/lexicon-resolvers.test.ts
index d9a8b14c..bbd5b23f 100644
--- a/src/__tests__/utils/lexicon-resolvers.test.ts
+++ b/src/__tests__/utils/lexicon-resolvers.test.ts
@@ -1,8 +1,17 @@
///
import type { SenseRef } from 'interlinearizer';
-import type { LexiconCapabilities, LexiconResolver, ResolvedSense } from 'interlinearizer/lexicon';
-import { createLexiconRegistry, nullLexiconResolver } from '../../utils/lexicon-resolvers';
+import type {
+ LexiconCapabilities,
+ LexiconProvider,
+ LexiconResolver,
+ ResolvedSense,
+} from 'interlinearizer/lexicon';
+import {
+ connectLexiconRegistry,
+ createLexiconRegistry,
+ nullLexiconResolver,
+} from '../../utils/lexicon-resolvers';
const NO_CAPABILITIES: LexiconCapabilities = {
search: false,
@@ -129,3 +138,68 @@ describe('createLexiconRegistry', () => {
);
});
});
+
+/** A provider whose connections are observable, so a test can assert which lexicon it was given. */
+function stubProvider(authority: string): LexiconProvider & { connect: jest.Mock } {
+ return {
+ authority,
+ isAvailable: jest.fn(async () => true),
+ connect: jest.fn((lexiconId?: string) =>
+ stubResolver([authority], { ...NO_CAPABILITIES, search: !!lexiconId }),
+ ),
+ };
+}
+
+describe('connectLexiconRegistry', () => {
+ it('connects the provider the link names to the lexicon it names', () => {
+ const mine = stubProvider('mine');
+
+ connectLexiconRegistry([mine], { authority: 'mine', lexiconId: 'lex-1' });
+
+ expect(mine.connect).toHaveBeenCalledWith('lex-1');
+ });
+
+ it('connects a provider the link does not name to no lexicon', () => {
+ const other = stubProvider('other');
+
+ connectLexiconRegistry([other], { authority: 'mine', lexiconId: 'lex-1' });
+
+ expect(other.connect).toHaveBeenCalledWith(undefined);
+ });
+
+ it('connects every provider to no lexicon when the project is linked to none', () => {
+ const mine = stubProvider('mine');
+
+ connectLexiconRegistry([mine]);
+
+ expect(mine.connect).toHaveBeenCalledWith(undefined);
+ });
+
+ it('answers for an available provider connected to nothing, so its refs are not foreign', () => {
+ const registry = connectLexiconRegistry([stubProvider('mine')]);
+
+ expect(registry.isForeign(senseRef('mine'))).toBe(false);
+ });
+
+ it('calls a ref foreign when no available provider declares its authority', () => {
+ const registry = connectLexiconRegistry([stubProvider('mine')]);
+
+ expect(registry.isForeign(senseRef('other'))).toBe(true);
+ });
+
+ it('offers the capabilities of a provider connected to a lexicon', () => {
+ const registry = connectLexiconRegistry([stubProvider('mine')], {
+ authority: 'mine',
+ lexiconId: 'lex-1',
+ });
+
+ expect(registry.resolverWith('search')).toBeDefined();
+ });
+
+ it('offers nothing while no provider can be reached', () => {
+ const registry = connectLexiconRegistry([]);
+
+ expect(registry.resolverWith('search')).toBeUndefined();
+ expect(registry.isForeign(senseRef('mine'))).toBe(true);
+ });
+});
diff --git a/src/hooks/useLexiconRegistry.ts b/src/hooks/useLexiconRegistry.ts
index 05645dcf..8a7b2811 100644
--- a/src/hooks/useLexiconRegistry.ts
+++ b/src/hooks/useLexiconRegistry.ts
@@ -1,13 +1,53 @@
+import { useProjectSetting } from '@papi/frontend/react';
+import type { LexiconLink, LexiconProvider } from 'interlinearizer/lexicon';
+import { useEffect, useMemo, useState } from 'react';
+import { fwLiteLexiconProvider } from '../utils/fw-lite-lexicon';
import type { LexiconRegistry } from '../utils/lexicon-resolvers';
-import { createLexiconRegistry, nullLexiconResolver } from '../utils/lexicon-resolvers';
+import { connectLexiconRegistry } from '../utils/lexicon-resolvers';
-/** Assembled once for the session, so its answers never vary by component or by render. */
-const sessionRegistry = createLexiconRegistry([nullLexiconResolver]);
+/** The lexicon software a project can be linked to. */
+const PROVIDERS: readonly LexiconProvider[] = [fwLiteLexiconProvider];
+
+/** Reads a project setting as a string, treating a platform error or a pending load as unset. */
+function asSetting(value: unknown): string {
+ return typeof value === 'string' ? value : '';
+}
/**
* The one place the UI asks about the lexicon, so no component asks whether one particular lexicon
* is connected.
+ *
+ * Answers for the project in view rather than for the session: a project is linked to one lexicon
+ * and more than one project can be open. Until the software has answered whether it can be reached,
+ * the registry is the one that holds nothing, so a consumer renders the no-lexicon shape rather
+ * than waiting on a lexicon that may not exist.
*/
-export default function useLexiconRegistry(): LexiconRegistry {
- return sessionRegistry;
+export default function useLexiconRegistry(projectId: string): LexiconRegistry {
+ const [storedAuthority] = useProjectSetting(projectId, 'interlinearizer.lexiconAuthority', '');
+ const [storedLexiconCode] = useProjectSetting(projectId, 'interlinearizer.lexiconCode', '');
+ const [availableProviders, setAvailableProviders] = useState([]);
+
+ useEffect(() => {
+ let ignore = false;
+ (async () => {
+ const availability = await Promise.all(PROVIDERS.map((provider) => provider.isAvailable()));
+ if (!ignore) setAvailableProviders(PROVIDERS.filter((_, index) => availability[index]));
+ })();
+ return () => {
+ ignore = true;
+ };
+ }, []);
+
+ // Half a link names no lexicon, so either half missing leaves the project glossing without one.
+ // That is also how a user drops a link: clearing the lexicon code in the project settings.
+ const link = useMemo(() => {
+ const authority = asSetting(storedAuthority);
+ const lexiconId = asSetting(storedLexiconCode);
+ return authority && lexiconId ? { authority, lexiconId } : undefined;
+ }, [storedAuthority, storedLexiconCode]);
+
+ return useMemo(
+ () => connectLexiconRegistry(availableProviders, link),
+ [availableProviders, link],
+ );
}
diff --git a/src/main.ts b/src/main.ts
index fcf59977..53935bc9 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -13,6 +13,7 @@ import interlinearizerStyles from './interlinearizer.web-view.scss?inline';
import * as projectStorage from './services/projectStorage';
import * as pt9ImportService from './services/pt9ImportService';
import { isDraftProject, isSegmentationDelta, isTextAnalysis } from './types/type-guards';
+import { LEXICON_AUTHORITIES } from './utils/lexicon-authorities';
// #region WebView provider
@@ -573,6 +574,32 @@ export async function activate(context: ExecutionActivationContext): Promise {
+ return Promise.resolve(newValue === '' || LEXICON_AUTHORITIES.includes(String(newValue)));
+ }
+
+ /** Returns whether the supplied project-setting value is a string. */
+ /* v8 ignore next 3 */
+ function isString(newValue: unknown): Promise {
+ return Promise.resolve(typeof newValue === 'string');
+ }
+
+ const lexiconAuthorityValidatorRegistration = await papi.projectSettings.registerValidator(
+ 'interlinearizer.lexiconAuthority',
+ isLexiconAuthority,
+ );
+
+ const lexiconCodeValidatorRegistration = await papi.projectSettings.registerValidator(
+ 'interlinearizer.lexiconCode',
+ isString,
+ );
+
const createProjectCommandRegistration = await papi.commands.registerCommand(
'interlinearizer.createProject',
createInterlinearProject,
@@ -1000,6 +1027,8 @@ export async function activate(context: ExecutionActivationContext): Promise;
+
+/** One sense of a lexicon entry, narrowed to what a gloss is resolved from. */
+export interface LexiconSense {
+ id: string;
+ gloss: LexiconMultiString;
+}
+
+/** One lexicon entry, narrowed to what a gloss is resolved from. */
+export interface LexiconEntry {
+ id: string;
+
+ /** The form the entry is listed under. */
+ lexemeForm: LexiconMultiString;
+
+ senses: LexiconSense[];
+}
+
+/** A new entry, carrying only the fields this extension sets and leaving the rest to the lexicon. */
+export interface PartialLexiconEntry {
+ lexemeForm?: LexiconMultiString;
+ senses?: { gloss?: LexiconMultiString }[];
+}
+
+/**
+ * Narrows an entry search. A query narrowing by neither a surface form nor a semantic domain
+ * matches nothing rather than everything.
+ */
+export interface LexiconEntryQuery {
+ readonly surfaceForm?: string;
+ readonly exactMatch?: boolean;
+ readonly partOfSpeech?: string;
+ readonly semanticDomain?: string;
+}
+
+/**
+ * Reads and writes one lexicon at a time, named by its FW Lite lexicon code. The service holds no
+ * notion of a Paratext project, so which lexicon a project is linked to is this extension's own
+ * record to keep.
+ */
+export interface LexiconEntryService {
+ /** @returns The matching entries, or `undefined` when the lexicon cannot be read. */
+ getEntries(lexiconCode: string, query: LexiconEntryQuery): Promise;
+
+ /** @returns The sense, or `undefined` when the lexicon has no such sense. */
+ getSense(lexiconCode: string, id: string): Promise;
+
+ /**
+ * Adds an entry to the lexicon.
+ *
+ * @returns The created entry, carrying the ids the lexicon minted for it, or `undefined` when the
+ * lexicon cannot be written to.
+ */
+ addEntry(lexiconCode: string, entry: PartialLexiconEntry): Promise;
+}
diff --git a/src/types/lexicon-port.d.ts b/src/types/lexicon-port.d.ts
index a463060f..3f718921 100644
--- a/src/types/lexicon-port.d.ts
+++ b/src/types/lexicon-port.d.ts
@@ -10,6 +10,21 @@
declare module 'interlinearizer/lexicon' {
import type { EntryRef, LexiconAuthority, MultiString, SenseRef } from 'interlinearizer';
+ /**
+ * The one lexicon a Paratext project is linked to. Both halves are needed to name it: an
+ * authority alone does not say which of its lexicons, and a lexicon id alone does not say whose
+ * id space it belongs to.
+ */
+ export interface LexiconLink {
+ authority: LexiconAuthority;
+
+ /**
+ * Names the lexicon within `authority`, in the same form a `LexiconRef` carries as its
+ * `projectId`.
+ */
+ lexiconId: string;
+ }
+
/**
* What a lexicon holds and permits, split finely enough to gate one affordance at a time. An
* affordance is a piece of UI the user can act on, such as a lexicon search field or an "add to
@@ -164,4 +179,35 @@ declare module 'interlinearizer/lexicon' {
*/
createEntry: (draft: EntryDraft) => Promise;
}
+
+ /**
+ * One lexicon software a project can be linked to, and the way to reach the lexicons it holds.
+ *
+ * Two lifetimes are kept apart. A provider is _available_ for as long as the software behind it
+ * can be reached, which is a fact about the session. It is _connected_ to one lexicon per linked
+ * project, which is a fact about a project, so several connections can be live at once.
+ */
+ export interface LexiconProvider {
+ /** The id space the lexicons behind this provider mint ids in and answer for. */
+ authority: LexiconAuthority;
+
+ /**
+ * Whether the software behind this provider can be reached in this session. Reaching it may
+ * mean waiting for it to start, so this answers late rather than wrongly.
+ *
+ * @returns `false` for software that is absent or does not answer in time, which is an ordinary
+ * configuration rather than a fault: the Interlinearizer glosses with no lexicon at all.
+ */
+ isAvailable: () => Promise;
+
+ /**
+ * Connects to the lexicon a project is linked to.
+ *
+ * @param lexiconId - Names the lexicon within {@link LexiconProvider.authority}, in the form a
+ * {@link LexiconLink} holds it. Omitted for a project with no link, which yields a resolver
+ * that declares the authority and holds nothing - so a ref this software minted reads as a
+ * miss rather than as foreign while no lexicon is linked.
+ */
+ connect: (lexiconId?: string) => LexiconResolver;
+ }
}
diff --git a/src/utils/fw-lite-lexicon.ts b/src/utils/fw-lite-lexicon.ts
new file mode 100644
index 00000000..43605555
--- /dev/null
+++ b/src/utils/fw-lite-lexicon.ts
@@ -0,0 +1,138 @@
+import papi, { logger } from '@papi/frontend';
+import type {
+ LexiconProvider,
+ LexiconResolver,
+ ResolvedSense,
+ SenseCandidate,
+} from 'interlinearizer/lexicon';
+import type { LexiconEntry, LexiconEntryService, LexiconSense } from '../types/lexicon-extension';
+import { FW_LITE_AUTHORITY } from './lexicon-authorities';
+
+/** Id of the Lexicon extension's network service, the only way in to FieldWorks Lite. */
+const ENTRY_SERVICE_ID = 'lexicon.entryService';
+
+/**
+ * How long the Lexicon extension is given to register its service before FieldWorks Lite counts as
+ * absent. Long enough to cover that extension activating after this one, since activation is not
+ * ordered by dependency.
+ */
+const AVAILABILITY_TIMEOUT_MS = 10_000;
+
+/** Cached once found, so a session pays the wait once rather than per connection. */
+let entryService: LexiconEntryService | undefined;
+
+/**
+ * Reaches the Lexicon extension's entry service, waiting for it to be registered in case that
+ * extension has not finished activating.
+ *
+ * @returns The service, or `undefined` when nothing registers it in time - the shape of running
+ * without FieldWorks Lite installed.
+ */
+async function getEntryService(): Promise {
+ if (entryService) return entryService;
+ try {
+ await papi.networkObjectStatus.waitForNetworkObject(
+ { id: ENTRY_SERVICE_ID },
+ AVAILABILITY_TIMEOUT_MS,
+ );
+ entryService = await papi.networkObjects.get(ENTRY_SERVICE_ID);
+ } catch (e) {
+ logger.debug('Interlinearizer: the lexicon entry service is unavailable', e);
+ }
+ return entryService;
+}
+
+/** Discards the cached service so the next look-up starts over. */
+export function resetEntryServiceForTesting(): void {
+ entryService = undefined;
+}
+
+/**
+ * Maps a lexicon sense to what the Interlinearizer displays. The gloss carries over as it stands;
+ * FieldWorks Lite holds a definition as rich text and labels senses not at all, so neither has a
+ * plain form to carry over yet.
+ */
+function toResolvedSense(sense: LexiconSense): ResolvedSense {
+ return { gloss: sense.gloss };
+}
+
+/** Names every sense of `entry` for linking, alongside the form the entry is listed under. */
+function toCandidates(entry: LexiconEntry, lexiconCode: string): SenseCandidate[] {
+ return entry.senses.map((sense) => ({
+ ...toResolvedSense(sense),
+ lexemeForm: entry.lexemeForm,
+ ref: { authority: FW_LITE_AUTHORITY, projectId: lexiconCode, senseId: sense.id },
+ }));
+}
+
+/**
+ * One connection to one FieldWorks Lite lexicon, or to none.
+ *
+ * With no lexicon connected the resolver still declares the authority, so a ref FW Lite minted
+ * reads as a miss rather than as foreign, and it offers no capability, so nothing invites the user
+ * to search or add to a lexicon that is not there.
+ */
+function createResolver(lexiconCode?: string): LexiconResolver {
+ const connected = !!lexiconCode;
+ return {
+ authorities: [FW_LITE_AUTHORITY],
+ capabilities: {
+ search: connected,
+ create: connected,
+ // MiniLcm records neither: an entry carries one lexeme form and one morph type rather than a
+ // set of allomorphs, and a sense carries a part of speech without the inflection class and
+ // stem features an analysis would need.
+ allomorphs: false,
+ msas: false,
+ },
+
+ resolveSense: async (ref) => {
+ // A ref naming another lexicon misses, whether or not that lexicon exists: the connected one
+ // is the only lexicon this resolver answers for, so a relink leaves old refs to render as the
+ // free-form gloss stored beside them.
+ if (!lexiconCode || ref.projectId !== lexiconCode) return undefined;
+ const sense = await (await getEntryService())?.getSense(lexiconCode, ref.senseId);
+ return sense ? toResolvedSense(sense) : undefined;
+ },
+
+ searchByForm: async (form, options) => {
+ if (!lexiconCode) return [];
+ const entries =
+ (await (await getEntryService())?.getEntries(lexiconCode, { surfaceForm: form })) ?? [];
+ // The backend query narrows by form alone, so a writing system narrows the results here.
+ const writingSystem = options?.writingSystem;
+ const candidates = entries
+ .filter((entry) => !writingSystem || entry.lexemeForm[writingSystem] !== undefined)
+ .flatMap((entry) => toCandidates(entry, lexiconCode));
+ return options?.limit === undefined ? candidates : candidates.slice(0, options.limit);
+ },
+
+ createEntry: async (draft) => {
+ if (!lexiconCode) throw new Error('No lexicon is connected to create an entry in.');
+ const service = await getEntryService();
+ if (!service) throw new Error('The lexicon is unreachable, so no entry was created.');
+
+ // One sense always, gloss or none: a created entry is only useful here if a gloss can link to
+ // a sense of it.
+ const entry = await service.addEntry(lexiconCode, {
+ lexemeForm: { [draft.writingSystem]: draft.form },
+ senses: [{ gloss: draft.gloss ?? {} }],
+ });
+ const senseId = entry?.senses[0]?.id;
+ if (!entry || !senseId) {
+ throw new Error('The lexicon reported no entry and sense to link a gloss to.');
+ }
+ return {
+ entryRef: { authority: FW_LITE_AUTHORITY, projectId: lexiconCode, entryId: entry.id },
+ senseRef: { authority: FW_LITE_AUTHORITY, projectId: lexiconCode, senseId },
+ };
+ },
+ };
+}
+
+/** FieldWorks Lite, reached through the Lexicon extension. */
+export const fwLiteLexiconProvider: LexiconProvider = {
+ authority: FW_LITE_AUTHORITY,
+ isAvailable: async () => (await getEntryService()) !== undefined,
+ connect: createResolver,
+};
diff --git a/src/utils/lexicon-authorities.ts b/src/utils/lexicon-authorities.ts
new file mode 100644
index 00000000..253a8e4b
--- /dev/null
+++ b/src/utils/lexicon-authorities.ts
@@ -0,0 +1,14 @@
+import type { LexiconAuthority } from 'interlinearizer';
+
+/**
+ * The id space of every lexicon FieldWorks Lite holds. One space, not one per backing store: FW
+ * Lite syncs a lexicon between its FwData and CRDT copies while preserving entry ids, so splitting
+ * the space would strand a lexicon's existing refs the moment it gained a second copy.
+ */
+export const FW_LITE_AUTHORITY: LexiconAuthority = 'fw-lite';
+
+/**
+ * Every authority a project may be linked to. A link naming anything else names no lexicon, which
+ * leaves the project glossing without one rather than failing.
+ */
+export const LEXICON_AUTHORITIES: readonly LexiconAuthority[] = [FW_LITE_AUTHORITY];
diff --git a/src/utils/lexicon-resolvers.ts b/src/utils/lexicon-resolvers.ts
index dcdd0c45..900ce042 100644
--- a/src/utils/lexicon-resolvers.ts
+++ b/src/utils/lexicon-resolvers.ts
@@ -1,5 +1,11 @@
import type { LexiconAuthority, LexiconRef, SenseRef } from 'interlinearizer';
-import type { LexiconCapability, LexiconResolver, ResolvedSense } from 'interlinearizer/lexicon';
+import type {
+ LexiconCapability,
+ LexiconLink,
+ LexiconProvider,
+ LexiconResolver,
+ ResolvedSense,
+} from 'interlinearizer/lexicon';
/** The lexicon that holds nothing: the shape of the Interlinearizer running with no lexicon. */
export const nullLexiconResolver: LexiconResolver = {
@@ -13,7 +19,7 @@ export const nullLexiconResolver: LexiconResolver = {
};
/**
- * The lexicons connected for the session, ordinarily one, and none is a supported configuration.
+ * The lexicons connected for one project, ordinarily one, and none is a supported configuration.
*
* A connection is not what decides where a ref goes; the authority stamped on the ref is, because a
* project keeps the refs of whatever lexicon glossed it whether or not that lexicon is connected. A
@@ -61,3 +67,25 @@ export function createLexiconRegistry(resolvers: readonly LexiconResolver[]): Le
resolveSense: async (ref) => resolversByAuthority.get(ref.authority)?.resolveSense(ref),
};
}
+
+/**
+ * Assembles the registry for one project over the software that can be reached, with the lexicon
+ * the project is linked to connected.
+ *
+ * Availability and connection are separate: software that is reachable but holds no lexicon for
+ * this project still answers for its authority, so the refs it minted read as misses rather than as
+ * foreign - which is what tells a project that has been relinked apart from one glossed by a
+ * lexicon nobody here has.
+ *
+ * @param link - Omitted for a project linked to no lexicon.
+ */
+export function connectLexiconRegistry(
+ availableProviders: readonly LexiconProvider[],
+ link?: LexiconLink,
+): LexiconRegistry {
+ return createLexiconRegistry(
+ availableProviders.map((provider) =>
+ provider.connect(provider.authority === link?.authority ? link.lexiconId : undefined),
+ ),
+ );
+}