Skip to content

Add the lexicon provider plumbing behind the chooser - #308

Draft
imnasnainaec wants to merge 1 commit into
mainfrom
feat/lexicon-provider-plumbing
Draft

Add the lexicon provider plumbing behind the chooser#308
imnasnainaec wants to merge 1 commit into
mainfrom
feat/lexicon-provider-plumbing

Conversation

@imnasnainaec

@imnasnainaec imnasnainaec commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

The plumbing a lexicon chooser needs, with no chooser: a LexiconProvider on the port, a registry
scoped to the project in view, the project setting recording which lexicon that project is linked
to, and FieldWorks Lite registered behind it as the first provider.

Nothing in the registry, the stored link, or the analysis model names FieldWorks Lite. A second
provider later — the open question in #46 — means registering one, not revising this.

Partially addresses #44; the chooser and the first-open offer are still open there.

What this adds

LexiconProvider on the port (src/types/lexicon-port.d.ts) — { authority, isAvailable(), connect(lexiconId?) }, alongside the existing LexiconResolver. It keeps two lifetimes apart:
availability is a fact about the session, connection is a fact about a project, so several
connections can be live at once.

connect() with no lexicon id is the load-bearing case. It yields a resolver that declares the
authority and holds nothing, so a project with no link reads a FieldWorks Lite ref as a miss
rather than as foreign — the same rendered output, but a different fact, and the one #227's link
indicator will need to distinguish.

LexiconLink{ authority, lexiconId }, the one lexicon a Paratext project is linked to.

A project-scoped registry. useLexiconRegistry(projectId) reads the project's link, asks each
provider whether it can be reached, and connects the one the link names.
connectLexiconRegistry(availableProviders, link?) does the assembly and is where the
available-vs-connected distinction is enforced. The old session-wide sessionRegistry"assembled
once for the session, so its answers never vary by component or by render"
— contradicted a
per-project link, since more than one project can be open.

The link setting, two scalars per the storage decision below, contributed in
contributions/projectSettings.json and validated in main.ts. The lexicon code is visible in
project settings so a user can clear it (this is how a project drops its link); the authority is
isHidden, matching how the Lexicon extension treats lexicon.analysisLanguage.

The FieldWorks Lite provider (src/utils/fw-lite-lexicon.ts) — availability by waiting for the
lexicon.entryService network object, and a resolver over getSense / getEntries / addEntry.

Decisions

The link is our own project setting, not lexicon.lexiconCode. That setting enforces the same
one-lexicon-per-project rule and already has the sticky-change behavior we want, but an FW Lite
lexicon code cannot name a non-FW-Lite lexicon, so #46's options would need a second mechanism. It
is also cleared by the Lexicon extension itself when a lexicon stops resolving.

Two scalar settings, not one JSON value. interlinearizer.lexiconAuthority +
interlinearizer.lexiconCode matches every project setting in either extension and stays readable
and hand-clearable in the settings UI. The cost is that a half-updated link is representable, so
reads treat either half missing as no link at all.

No mirroring into lexicon.lexiconCode. Two settings now describe one fact and can drift — but
sillsdev/languageforge-lexbox#2626 makes IEntryService lexicon-addressed precisely so drift cannot
misroute a read or a write. Mirroring is one line to add later if the visible disagreement turns out
to matter; nothing here forecloses it.

One fw-lite authority, whichever store backs the lexicon. FW Lite syncs a lexicon between its
FwData and CRDT copies while preserving entry ids, so there is one id space. Splitting it per
backing store would strand every existing ref the moment a lexicon gained a second copy.

A ref naming a lexicon other than the connected one misses. Deliberate, and the consequence of
relinking being hard: after a change, previously linked glosses render as their stored free-form
text and the refs survive untouched. A ref carrying no lexicon id misses too — the lexicon in view
is never taken as the one meant.

Capabilities are search/create when connected, allomorphs/msas never. MiniLcm records no
allomorphs (an entry carries one lexeme form and one morph type; variants relate separate entries)
and no MSAs (a sense carries a part of speech, without the inflection class and stem features an
analysis needs). Splitting a partOfSpeech capability out of msas is worth doing when a POS
affordance is actually designed.

Two things reviewers should weigh

The Lexicon extension's types are restated locally (src/types/lexicon-extension.ts). Its own
declarations reach a build only through paranext-core/dev-appdata/cache/extension-types, which is
populated by installing that extension — something CI (which checks out a fresh paranext-core) and
a fresh clone cannot assume. So the handful of shapes we consume are declared here structurally, and
that extension's declarations remain the standard. The alternative, augmenting papi-shared-types
with its command and network-object types, risks a merge conflict for anyone who does have both
installed.

resolveSense / searchByForm / createEntry are implemented here rather than left to #227.
The plan comment assigned the resolver to #227, but what remains of #227 once these exist is the
display wiring — which is UI, and out of scope for this PR by the same rule that leaves out the
chooser. Say so if you would rather this PR stopped at connect() returning a resolver that
declares the authority and nothing else.

Two mapping decisions are deliberately deferred to #227, since both are about presentation: a
sense's definition (FW Lite holds it as rich text) and its senseLabel (FW Lite does not number
senses). resolveSense carries the gloss; a search candidate also carries its entry's lexeme form.

Deliberately not added

listLexicons(), createLexicon(draft), and displayName on LexiconProvider. The plan sketched
them for a chooser the Interlinearizer would build itself; the decision to reuse the Lexicon
extension's own selector means the choosing affordance belongs to the provider (an FW Lite provider
would call lexicon.chooseLexicon), not to a list the Interlinearizer renders. Adding them now
would be API nothing calls, shaped for a chooser we are not building.

Housekeeping

The "Current Lexicon gap" notes on EntryRef and SenseRef said no by-id lookup exists; getEntry
and getSense have since landed, so those notes and the summary list are updated. The allomorph and
MSA gaps stand.

Test plan

  • npm run lint (ESLint + stylelint + tsc --noEmit + dependency scope) — clean.
  • npm run test:coverage — 2412 tests pass, and the 100% threshold holds; the four touched or added
    source files are each at 100%.
  • New: src/__tests__/utils/fw-lite-lexicon.test.ts covers availability (including a service that
    never registers, and one announced but unfetchable), the unconnected resolver, refs naming another
    lexicon or none, search narrowing and capping, and entry creation.
  • Extended: useLexiconRegistry (first-render shape, per-project links, half a link, an unreadable
    setting) and connectLexiconRegistry.
  • Not exercised end-to-end: nothing calls connect() with a real lexicon yet, because nothing writes
    the link setting until the chooser lands. Setting interlinearizer.lexiconAuthority to fw-lite
    and interlinearizer.lexiconCode to a real FW Lite lexicon code by hand is the way to try it
    against a running Lexicon extension.

Depends on

sillsdev/languageforge-lexbox#2627 (for sillsdev/languageforge-lexbox#2626) — this consumes IEntryService in its lexicon-addressed form.
Merge that first.


This change is Reviewable

Introduce LexiconProvider on the port, scope the lexicon registry to the
project in view, record the project-to-lexicon link in two project settings,
and register FieldWorks Lite as the first provider behind it.

Availability and connection are kept apart: software that can be reached but
holds no lexicon for this project still answers for its authority, so the refs
it minted read as misses rather than as foreign.

No chooser and no first-open offer; both stay open on the issue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@imnasnainaec imnasnainaec self-assigned this Sep 4, 2026
@imnasnainaec

imnasnainaec commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

The FW Lite adapter trusts the entry service's shapes

src/types/lexicon-extension.ts declares gloss, senses, and lexemeForm required, but they arrive from another extension over PAPI, so nothing enforces that where they are read:

src/utils/fw-lite-lexicon.ts On a response missing the field
entry?.senses[0]?.id (:121) TypeError — the optional chain guards entry, not senses
entry.senses.map(…) (:61) TypeError
entry.lexemeForm[writingSystem] (:105) TypeError
{ gloss: sense.gloss } (:56) Required MultiString holding undefined — a gloss that renders as nothing, no clue why

The three throws are defects either way; entry?.senses[0]?.id guards the wrong link in its own chain.

How far to narrow is open:

  • Optional-chain the three reads. Smallest. Leaves gloss unsound.
  • Narrow per record, dropping what carries no gloss, senses, or lexeme form. Drift shows up as a missing candidate rather than a later render failure. Needs a test per malformed shape for the 100% threshold.
  • Same, plus a logger.debug naming the dropped field. Diagnosable from a log; more code in a path that should never run.
  • Leave it, treating the declared types as the contract. Cheapest, and matches how platform-scripture's types are trusted elsewhere — except those ship from the extension producing the data, while these are restated here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant