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), + ), + ); +}