From 4d6335f4c583acc88e9914562777806f69d446b5 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 30 Jun 2026 11:47:30 -0400 Subject: [PATCH 01/20] Add createProject to FwLiteApi, fix empty-body fetch handling `fetchUrl` now reads the response as text before conditionally parsing JSON, so endpoints that return an empty 200 (like DELETE entry and the new project/create route) no longer throw a SyntaxError. `FwLiteApi.createProject` wraps `POST /api/project/create`, which was added in #2281 to resolve #1920. Closes #2061. Co-Authored-By: Claude Opus 4.8 --- platform.bible-extension/src/utils/fw-lite-api.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/platform.bible-extension/src/utils/fw-lite-api.ts b/platform.bible-extension/src/utils/fw-lite-api.ts index 11baf67bbf..d7878bd386 100644 --- a/platform.bible-extension/src/utils/fw-lite-api.ts +++ b/platform.bible-extension/src/utils/fw-lite-api.ts @@ -43,7 +43,9 @@ async function fetchUrl(input: string, init?: RequestInit): Promise { if (!results.ok) { throw new Error(`Failed to fetch: ${results.status} ${results.statusText}`); } - return await results.json(); + const text = await results.text(); + // eslint-disable-next-line no-type-assertion/no-type-assertion + return text ? (JSON.parse(text) as unknown) : undefined; } export function getBrowseUrl(baseUrl: string, lexiconCode: string, entryId?: string): string { @@ -164,6 +166,17 @@ export class FwLiteApi { /* eslint-enable no-type-assertion/no-type-assertion */ + async createProject( + name: string, + code: string, + vernacularWs: string, + analysisWs?: string, + ): Promise { + const params = new URLSearchParams({ name, code, vernacularWs }); + if (analysisWs) params.append('analysisWs', analysisWs); + await this.fetchPath(`project/create?${params.toString()}`, 'POST'); + } + private checkLexiconCode(lexiconCode?: string): LexiconRef { const code = sanitizeUrlComponent(lexiconCode || this.lexiconCode); return { code, type: 'FwData' }; From 2b91c54050643ed51fc48704449e422c9522b6a6 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 20 Jul 2026 16:57:35 -0400 Subject: [PATCH 02/20] Add Create new lexicon option to SelectLexicon WebView Adds an end-to-end flow for creating a new FW Lite CRDT project from within the SelectLexicon dialog: - New `lexicon.createLexicon` PAPI command wrapping FwLiteApi.createProject - New CreateLexicon form component (name, auto-derived code, vernacular WS pre-filled from the Paratext project language, optional analysis WS) - "Create new lexicon" button in LexiconComboBox opens the form - On successful creation the new lexicon is auto-selected and the dialog shows the standard "Lexicon selection saved" confirmation Co-Authored-By: Claude Sonnet 4.6 --- .../contributions/localizedStrings.json | 8 ++ .../src/components/create-lexicon.tsx | 125 ++++++++++++++++++ .../src/components/lexicon-combo-box.tsx | 8 ++ platform.bible-extension/src/main.ts | 14 ++ .../src/types/lexicon.d.ts | 6 + .../src/types/localized-string-keys.ts | 8 ++ .../src/utils/project-manager.ts | 10 +- .../src/web-views/select-lexicon.web-view.tsx | 92 ++++++++++--- 8 files changed, 252 insertions(+), 19 deletions(-) create mode 100644 platform.bible-extension/src/components/create-lexicon.tsx diff --git a/platform.bible-extension/contributions/localizedStrings.json b/platform.bible-extension/contributions/localizedStrings.json index 8e4699ff7d..e7aef3a39a 100644 --- a/platform.bible-extension/contributions/localizedStrings.json +++ b/platform.bible-extension/contributions/localizedStrings.json @@ -3,6 +3,14 @@ "localizedStrings": { "en": { "%lexicon_addWord_buttonAdd%": "Add new entry", + "%lexicon_createLexicon_analysisWs%": "Analysis language tag (optional)", + "%lexicon_createLexicon_button%": "Create new lexicon", + "%lexicon_createLexicon_code%": "Code", + "%lexicon_createLexicon_error%": "Error creating lexicon:", + "%lexicon_createLexicon_name%": "Name", + "%lexicon_createLexicon_submit%": "Create", + "%lexicon_createLexicon_title%": "Create new lexicon", + "%lexicon_createLexicon_vernacularWs%": "Vernacular language tag", "%lexicon_addWord_buttonSubmit%": "Submit new entry", "%lexicon_addWord_title%": "Add entry to lexicon", "%lexicon_auth_login%": "Sign in to Lexbox", diff --git a/platform.bible-extension/src/components/create-lexicon.tsx b/platform.bible-extension/src/components/create-lexicon.tsx new file mode 100644 index 0000000000..a86af9dc51 --- /dev/null +++ b/platform.bible-extension/src/components/create-lexicon.tsx @@ -0,0 +1,125 @@ +import { logger } from '@papi/frontend'; +import { useLocalizedStrings } from '@papi/frontend/react'; +import { Button, Input, Label } from 'platform-bible-react'; +import { type ReactElement, useCallback, useEffect, useState } from 'react'; +import { LOCALIZED_STRING_KEYS } from '../types/localized-string-keys'; + +const CODE_PATTERN = /^[a-z\d][a-z\d-]*$/; + +function deriveCode(name: string): string { + return name + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^[^a-z0-9]+/, '') + .replace(/[^a-z0-9]+$/, ''); +} + +interface CreateLexiconProps { + createLexicon: ( + name: string, + code: string, + vernacularWs: string, + analysisWs?: string, + ) => Promise; + defaultVernacularWs?: string; + onCancel: () => void; + onCreated: (code: string) => Promise; +} + +/** A form for creating a new FW Lite CRDT project from the blank template. */ +export default function CreateLexicon({ + createLexicon, + defaultVernacularWs, + onCancel, + onCreated, +}: CreateLexiconProps): ReactElement { + const [localizedStrings] = useLocalizedStrings(LOCALIZED_STRING_KEYS); + + const [name, setName] = useState(''); + const [code, setCode] = useState(''); + const [codeEdited, setCodeEdited] = useState(false); + const [vernacularWs, setVernacularWs] = useState(defaultVernacularWs ?? ''); + const [analysisWs, setAnalysisWs] = useState(''); + const [creating, setCreating] = useState(false); + + useEffect(() => { + if (!codeEdited) setCode(deriveCode(name)); + }, [codeEdited, name]); + + const isValid = !!(name.trim() && CODE_PATTERN.test(code) && vernacularWs.trim()); + + const handleSubmit = useCallback(async () => { + if (!isValid) return; + setCreating(true); + try { + await createLexicon(name.trim(), code, vernacularWs.trim(), analysisWs.trim() || undefined); + await onCreated(code); + } catch (e) { + logger.error(localizedStrings['%lexicon_createLexicon_error%'], JSON.stringify(e)); + } finally { + setCreating(false); + } + }, [analysisWs, code, createLexicon, isValid, localizedStrings, name, onCreated, vernacularWs]); + + return ( +
+

+ {localizedStrings['%lexicon_createLexicon_title%']} +

+ +
+ + setName(e.target.value)} value={name} /> +
+ +
+ + { + setCode(e.target.value); + setCodeEdited(true); + }} + value={code} + /> +
+ +
+ + setVernacularWs(e.target.value)} + value={vernacularWs} + /> +
+ +
+ + setAnalysisWs(e.target.value)} + value={analysisWs} + /> +
+ +
+ + +
+
+ ); +} diff --git a/platform.bible-extension/src/components/lexicon-combo-box.tsx b/platform.bible-extension/src/components/lexicon-combo-box.tsx index cc8ce37b52..f13f146c37 100644 --- a/platform.bible-extension/src/components/lexicon-combo-box.tsx +++ b/platform.bible-extension/src/components/lexicon-combo-box.tsx @@ -8,12 +8,14 @@ import { LOCALIZED_STRING_KEYS } from '../types/localized-string-keys'; /** Props for the LexiconComboBox component */ interface LexiconComboBoxProps { lexicons?: IProjectModel[]; + onCreateNew?: () => void; selectLexicon: (lexiconCode: string) => Promise; } /** A combo-box for selecting a lexicon for a project. */ export default function LexiconComboBox({ lexicons, + onCreateNew, selectLexicon, }: LexiconComboBoxProps): ReactElement { const [localizedStrings] = useLocalizedStrings(LOCALIZED_STRING_KEYS); @@ -85,6 +87,12 @@ export default function LexiconComboBox({ )} + + {!!onCreateNew && ( + + )} ); } diff --git a/platform.bible-extension/src/main.ts b/platform.bible-extension/src/main.ts index 6dccfd177b..e0a96eb062 100644 --- a/platform.bible-extension/src/main.ts +++ b/platform.bible-extension/src/main.ts @@ -256,6 +256,19 @@ export async function activate(context: ExecutionActivationContext): Promise { + try { + await fwLiteApi.createProject(name, code, vernacularWs, analysisWs); + return { success: true }; + } catch (e) { + logger.error('Error creating lexicon:', JSON.stringify(e)); + return { success: false }; + } + }, + ); + const lexiconsCommandPromise = papi.commands.registerCommand( 'lexicon.lexicons', async (projectId?: string) => { @@ -284,6 +297,7 @@ export async function activate(context: ExecutionActivationContext): Promise Promise; 'lexicon.authServers': () => Promise; 'lexicon.browseLexicon': (webViewId: string) => Promise; + 'lexicon.createLexicon': ( + name: string, + code: string, + vernacularWs: string, + analysisWs?: string, + ) => Promise; 'lexicon.displayEntry': (projectId: string, entryId: string) => Promise; 'lexicon.findEntry': (webViewId: string, entry: string) => Promise; 'lexicon.findRelatedEntries': (webViewId: string, entry: string) => Promise; diff --git a/platform.bible-extension/src/types/localized-string-keys.ts b/platform.bible-extension/src/types/localized-string-keys.ts index 867321f2e5..b352d7bb09 100644 --- a/platform.bible-extension/src/types/localized-string-keys.ts +++ b/platform.bible-extension/src/types/localized-string-keys.ts @@ -2,6 +2,14 @@ import { LocalizeKey } from 'platform-bible-utils'; export const LOCALIZED_STRING_KEYS: LocalizeKey[] = [ '%lexicon_addWord_buttonAdd%', + '%lexicon_createLexicon_analysisWs%', + '%lexicon_createLexicon_button%', + '%lexicon_createLexicon_code%', + '%lexicon_createLexicon_error%', + '%lexicon_createLexicon_name%', + '%lexicon_createLexicon_submit%', + '%lexicon_createLexicon_title%', + '%lexicon_createLexicon_vernacularWs%', '%lexicon_addWord_buttonSubmit%', '%lexicon_addWord_title%', '%lexicon_auth_login%', diff --git a/platform.bible-extension/src/utils/project-manager.ts b/platform.bible-extension/src/utils/project-manager.ts index dff0d463e9..51c15edd0b 100644 --- a/platform.bible-extension/src/utils/project-manager.ts +++ b/platform.bible-extension/src/utils/project-manager.ts @@ -41,10 +41,12 @@ export class ProjectManager { } logger.info(`Lexicon not yet selected for project '${nameOrId}'`); - await this.openWebView(WebViewType.SelectLexicon, { - floatSize: { height: 500, width: 400 }, - type: 'float', - }); + const vernacularLanguage = await this.getLanguageTag(); + await this.openWebView( + WebViewType.SelectLexicon, + { floatSize: { height: 500, width: 400 }, type: 'float' }, + { vernacularLanguage }, + ); } async setLexiconCode(lexiconCode: string): Promise { diff --git a/platform.bible-extension/src/web-views/select-lexicon.web-view.tsx b/platform.bible-extension/src/web-views/select-lexicon.web-view.tsx index fb9c5e7643..a048d5e949 100644 --- a/platform.bible-extension/src/web-views/select-lexicon.web-view.tsx +++ b/platform.bible-extension/src/web-views/select-lexicon.web-view.tsx @@ -1,14 +1,34 @@ -import type { WebViewProps } from '@papi/core'; import { commands, logger } from '@papi/frontend'; -import type { IProjectModel } from 'lexicon'; +import { useLocalizedStrings } from '@papi/frontend/react'; +import type { IProjectModel, LexiconWebViewProps } from 'lexicon'; import { useCallback, useEffect, useState } from 'react'; import AuthStatus from '../components/auth-status'; +import CreateLexicon from '../components/create-lexicon'; import LexiconComboBox from '../components/lexicon-combo-box'; +import { LOCALIZED_STRING_KEYS } from '../types/localized-string-keys'; import type { AuthServerStatus, LoginResult } from '../utils/fw-lite-api'; -globalThis.webViewComponent = function LexiconSelect({ projectId }: WebViewProps) { - const [lexicons, setLexicons] = useState(); +globalThis.webViewComponent = function LexiconSelect({ + projectId, + vernacularLanguage, +}: LexiconWebViewProps) { + const [localizedStrings] = useLocalizedStrings(LOCALIZED_STRING_KEYS); const [authServers, setAuthServers] = useState(); + const [done, setDone] = useState(false); + const [lexicons, setLexicons] = useState(); + const [showCreate, setShowCreate] = useState(false); + + const fetchLexicons = useCallback(() => { + commands + .sendCommand('lexicon.lexicons', projectId) + .then(setLexicons) + .catch((e) => logger.error('Error fetching lexicons:', JSON.stringify(e))); + }, [projectId]); + + useEffect(() => { + logger.info(`This WebView was opened for project '${projectId}'`); + fetchLexicons(); + }, [fetchLexicons, projectId]); // Keeps the last-known list when a refresh returns nothing, so the section doesn't vanish. const applyServers = useCallback( @@ -23,6 +43,10 @@ globalThis.webViewComponent = function LexiconSelect({ projectId }: WebViewProps .catch((e) => logger.error('Error fetching Lexbox auth servers:', JSON.stringify(e))); }, [applyServers]); + useEffect(() => { + refreshAuthServers(); + }, [refreshAuthServers]); + const login = useCallback( async (authority: string): Promise => { try { @@ -58,22 +82,60 @@ globalThis.webViewComponent = function LexiconSelect({ projectId }: WebViewProps [projectId], ); - useEffect(() => { - logger.info(`This WebView was opened for project '${projectId}'`); - commands - .sendCommand('lexicon.lexicons', projectId) - .then(setLexicons) - .catch((e) => logger.error('Error fetching lexicons:', JSON.stringify(e))); - }, [projectId]); + const createLexicon = useCallback( + async ( + name: string, + code: string, + vernacularWs: string, + analysisWs?: string, + ): Promise => { + const result = await commands.sendCommand( + 'lexicon.createLexicon', + name, + code, + vernacularWs, + analysisWs, + ); + if (!result?.success) throw new Error('Failed to create lexicon'); + }, + [], + ); - useEffect(() => { - refreshAuthServers(); - }, [refreshAuthServers]); + const onCreated = useCallback( + async (code: string): Promise => { + await selectLexicon(code); + setDone(true); + }, + [selectLexicon], + ); + + if (done) { + return ( +

+ {localizedStrings['%lexicon_selectLexicon_saved%']} +

+ ); + } + + if (showCreate) { + return ( + setShowCreate(false)} + onCreated={onCreated} + /> + ); + } return ( <> - + setShowCreate(true)} + selectLexicon={selectLexicon} + /> ); }; From 54748c2ea71af1cc2214d15bb2ec16a843109e3a Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 20 Jul 2026 17:01:17 -0400 Subject: [PATCH 03/20] Address review: fix CRDT type routing, error bodies, tag validation, key order - fw-lite-api: add static projectTypeByCode cache (shared across FwLiteApi instances including EntryService) so CRDT projects created via createProject route through mini-lcm/Harmony/... instead of mini-lcm/FwData/... getProjects() populates the cache; createProject() seeds 'Harmony' immediately so the writingSystems call in selectLexicon succeeds without a re-fetch - fw-lite-api: include response body text in error messages so backend validation failures (duplicate code, invalid lang tag, etc.) reach the logger - create-lexicon: validate vernacular and analysis language tags with Intl.getCanonicalLocales() before enabling the submit button - localized-string-keys / localizedStrings.json: restore alphabetical ordering Co-Authored-By: Claude Sonnet 4.6 --- .../contributions/localizedStrings.json | 18 +++++++++--------- .../src/components/create-lexicon.tsx | 15 ++++++++++++++- .../src/types/localized-string-keys.ts | 18 +++++++++--------- .../src/utils/fw-lite-api.ts | 19 ++++++++++++++----- 4 files changed, 46 insertions(+), 24 deletions(-) diff --git a/platform.bible-extension/contributions/localizedStrings.json b/platform.bible-extension/contributions/localizedStrings.json index e7aef3a39a..bbfe0b858a 100644 --- a/platform.bible-extension/contributions/localizedStrings.json +++ b/platform.bible-extension/contributions/localizedStrings.json @@ -3,18 +3,10 @@ "localizedStrings": { "en": { "%lexicon_addWord_buttonAdd%": "Add new entry", - "%lexicon_createLexicon_analysisWs%": "Analysis language tag (optional)", - "%lexicon_createLexicon_button%": "Create new lexicon", - "%lexicon_createLexicon_code%": "Code", - "%lexicon_createLexicon_error%": "Error creating lexicon:", - "%lexicon_createLexicon_name%": "Name", - "%lexicon_createLexicon_submit%": "Create", - "%lexicon_createLexicon_title%": "Create new lexicon", - "%lexicon_createLexicon_vernacularWs%": "Vernacular language tag", "%lexicon_addWord_buttonSubmit%": "Submit new entry", "%lexicon_addWord_title%": "Add entry to lexicon", - "%lexicon_auth_login%": "Sign in to Lexbox", "%lexicon_auth_loggingIn%": "Waiting for browser sign-in...", + "%lexicon_auth_login%": "Sign in to Lexbox", "%lexicon_auth_loginError%": "Error signing in to Lexbox:", "%lexicon_auth_logout%": "Log out", "%lexicon_auth_logoutError%": "Error signing out of Lexbox:", @@ -24,6 +16,14 @@ "%lexicon_auth_signInOffline%": "Can't reach Lexbox. Check your connection and try again.", "%lexicon_auth_signOutFailed%": "Sign-out didn't complete. Please try again.", "%lexicon_button_cancel%": "Cancel", + "%lexicon_createLexicon_analysisWs%": "Analysis language tag (optional)", + "%lexicon_createLexicon_button%": "Create new lexicon", + "%lexicon_createLexicon_code%": "Code", + "%lexicon_createLexicon_error%": "Error creating lexicon:", + "%lexicon_createLexicon_name%": "Name", + "%lexicon_createLexicon_submit%": "Create", + "%lexicon_createLexicon_title%": "Create new lexicon", + "%lexicon_createLexicon_vernacularWs%": "Vernacular language tag", "%lexicon_entryDisplay_definition%": "Definition", "%lexicon_entryDisplay_gloss%": "Gloss", "%lexicon_entryDisplay_headword%": "Headword", diff --git a/platform.bible-extension/src/components/create-lexicon.tsx b/platform.bible-extension/src/components/create-lexicon.tsx index a86af9dc51..ef38602ea4 100644 --- a/platform.bible-extension/src/components/create-lexicon.tsx +++ b/platform.bible-extension/src/components/create-lexicon.tsx @@ -6,6 +6,14 @@ import { LOCALIZED_STRING_KEYS } from '../types/localized-string-keys'; const CODE_PATTERN = /^[a-z\d][a-z\d-]*$/; +function isValidLangTag(tag: string): boolean { + try { + return Intl.getCanonicalLocales(tag).length === 1; + } catch { + return false; + } +} + function deriveCode(name: string): string { return name .toLowerCase() @@ -48,7 +56,12 @@ export default function CreateLexicon({ if (!codeEdited) setCode(deriveCode(name)); }, [codeEdited, name]); - const isValid = !!(name.trim() && CODE_PATTERN.test(code) && vernacularWs.trim()); + const isValid = !!( + name.trim() && + CODE_PATTERN.test(code) && + isValidLangTag(vernacularWs.trim()) && + (!analysisWs.trim() || isValidLangTag(analysisWs.trim())) + ); const handleSubmit = useCallback(async () => { if (!isValid) return; diff --git a/platform.bible-extension/src/types/localized-string-keys.ts b/platform.bible-extension/src/types/localized-string-keys.ts index b352d7bb09..e5e44c3c28 100644 --- a/platform.bible-extension/src/types/localized-string-keys.ts +++ b/platform.bible-extension/src/types/localized-string-keys.ts @@ -2,18 +2,10 @@ import { LocalizeKey } from 'platform-bible-utils'; export const LOCALIZED_STRING_KEYS: LocalizeKey[] = [ '%lexicon_addWord_buttonAdd%', - '%lexicon_createLexicon_analysisWs%', - '%lexicon_createLexicon_button%', - '%lexicon_createLexicon_code%', - '%lexicon_createLexicon_error%', - '%lexicon_createLexicon_name%', - '%lexicon_createLexicon_submit%', - '%lexicon_createLexicon_title%', - '%lexicon_createLexicon_vernacularWs%', '%lexicon_addWord_buttonSubmit%', '%lexicon_addWord_title%', - '%lexicon_auth_login%', '%lexicon_auth_loggingIn%', + '%lexicon_auth_login%', '%lexicon_auth_loginError%', '%lexicon_auth_logout%', '%lexicon_auth_logoutError%', @@ -23,6 +15,14 @@ export const LOCALIZED_STRING_KEYS: LocalizeKey[] = [ '%lexicon_auth_signInOffline%', '%lexicon_auth_signOutFailed%', '%lexicon_button_cancel%', + '%lexicon_createLexicon_analysisWs%', + '%lexicon_createLexicon_button%', + '%lexicon_createLexicon_code%', + '%lexicon_createLexicon_error%', + '%lexicon_createLexicon_name%', + '%lexicon_createLexicon_submit%', + '%lexicon_createLexicon_title%', + '%lexicon_createLexicon_vernacularWs%', '%lexicon_entryList_backToList%', '%lexicon_entryList_loading%', '%lexicon_entryList_noResults%', diff --git a/platform.bible-extension/src/utils/fw-lite-api.ts b/platform.bible-extension/src/utils/fw-lite-api.ts index d7878bd386..63cb790c1a 100644 --- a/platform.bible-extension/src/utils/fw-lite-api.ts +++ b/platform.bible-extension/src/utils/fw-lite-api.ts @@ -41,7 +41,8 @@ async function fetchUrl(input: string, init?: RequestInit): Promise { } const results = await papi.fetch(input, init); if (!results.ok) { - throw new Error(`Failed to fetch: ${results.status} ${results.statusText}`); + const errorBody = await results.text(); + throw new Error(errorBody || `Failed to fetch: ${results.status} ${results.statusText}`); } const text = await results.text(); // eslint-disable-next-line no-type-assertion/no-type-assertion @@ -55,6 +56,9 @@ export function getBrowseUrl(baseUrl: string, lexiconCode: string, entryId?: str } export class FwLiteApi { + // Shared across all instances (EntryService, main) — all talk to the same backend process. + private static readonly projectTypeByCode = new Map(); + private readonly baseUrl: string; private lexiconCode?: string; constructor(baseUrl: string, lexiconCode?: string) { @@ -110,11 +114,13 @@ export class FwLiteApi { } async getProjects(): Promise { - return (await this.fetchPath('localProjects')) as IProjectModel[]; + const projects = (await this.fetchPath('localProjects')) as IProjectModel[]; + projects.forEach((p) => FwLiteApi.projectTypeByCode.set(p.code, p.crdt ? 'Harmony' : 'FwData')); + return projects; } async getProjectsMatchingLanguage(langTag?: string): Promise { - const projects = (await this.fetchPath('localProjects')) as IProjectModel[]; + const projects = await this.getProjects(); if (!langTag?.trim()) return projects; try { @@ -175,11 +181,14 @@ export class FwLiteApi { const params = new URLSearchParams({ name, code, vernacularWs }); if (analysisWs) params.append('analysisWs', analysisWs); await this.fetchPath(`project/create?${params.toString()}`, 'POST'); + FwLiteApi.projectTypeByCode.set(code, 'Harmony'); } private checkLexiconCode(lexiconCode?: string): LexiconRef { - const code = sanitizeUrlComponent(lexiconCode || this.lexiconCode); - return { code, type: 'FwData' }; + const rawCode = lexiconCode || this.lexiconCode; + const code = sanitizeUrlComponent(rawCode); + const type = FwLiteApi.projectTypeByCode.get(rawCode ?? '') ?? 'FwData'; + return { code, type }; } private getUrl(path: string): string { From bbaacfd004ba95e3c8e3e03b2d0e6b08b5fde3b0 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 30 Jun 2026 16:00:31 -0400 Subject: [PATCH 04/20] Address review: deriveCode parity with backend, resilient onCreated - deriveCode now replaces invalid characters with '-' instead of removing them, matching the backend SanitizeProjectCode behavior - WebView onCreated catches selectLexicon failure: logs the error, refreshes the lexicon list, and falls back to the combo box so the user can manually select the project that was created on disk Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/create-lexicon.tsx | 2 +- .../src/web-views/select-lexicon.web-view.tsx | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/platform.bible-extension/src/components/create-lexicon.tsx b/platform.bible-extension/src/components/create-lexicon.tsx index ef38602ea4..013dec0702 100644 --- a/platform.bible-extension/src/components/create-lexicon.tsx +++ b/platform.bible-extension/src/components/create-lexicon.tsx @@ -18,7 +18,7 @@ function deriveCode(name: string): string { return name .toLowerCase() .replace(/\s+/g, '-') - .replace(/[^a-z0-9-]/g, '') + .replace(/[^a-z0-9-]/g, '-') .replace(/-+/g, '-') .replace(/^[^a-z0-9]+/, '') .replace(/[^a-z0-9]+$/, ''); diff --git a/platform.bible-extension/src/web-views/select-lexicon.web-view.tsx b/platform.bible-extension/src/web-views/select-lexicon.web-view.tsx index a048d5e949..7d378f890f 100644 --- a/platform.bible-extension/src/web-views/select-lexicon.web-view.tsx +++ b/platform.bible-extension/src/web-views/select-lexicon.web-view.tsx @@ -103,10 +103,18 @@ globalThis.webViewComponent = function LexiconSelect({ const onCreated = useCallback( async (code: string): Promise => { - await selectLexicon(code); - setDone(true); + try { + await selectLexicon(code); + setDone(true); + } catch (e) { + // Lexicon was created but auto-selection failed; go back to the combo box so + // the user can select it manually from the refreshed list. + logger.error('Error auto-selecting created lexicon:', JSON.stringify(e)); + fetchLexicons(); + setShowCreate(false); + } }, - [selectLexicon], + [fetchLexicons, selectLexicon], ); if (done) { From 180824a8435707c51fffaab4c50201ac35bc3fea Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 20 Jul 2026 17:01:51 -0400 Subject: [PATCH 05/20] Fix getBrowseUrl to use correct path segment for CRDT projects The Svelte frontend routes FwData projects via /paratext/fwdata/{code} and CRDT/Harmony projects via /paratext/project/{code}. The standalone getBrowseUrl always used 'fwdata', producing a broken URL for any Harmony lexicon. Move getBrowseUrl into FwLiteApi as a method so it can consult the projectTypeByCode cache and emit 'project' vs 'fwdata' accordingly. Co-Authored-By: Claude Sonnet 4.6 --- platform.bible-extension/src/main.ts | 6 +++--- platform.bible-extension/src/utils/fw-lite-api.ts | 14 ++++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/platform.bible-extension/src/main.ts b/platform.bible-extension/src/main.ts index e0a96eb062..76e3f72c42 100644 --- a/platform.bible-extension/src/main.ts +++ b/platform.bible-extension/src/main.ts @@ -6,7 +6,7 @@ import { getErrorMessage } from 'platform-bible-utils'; import { Stream } from 'stream'; import { EntryService } from './services/entry-service'; import { WebViewType } from './types/enums'; -import { FwLiteApi, getBrowseUrl, type LoginResult } from './utils/fw-lite-api'; +import { FwLiteApi, type LoginResult } from './utils/fw-lite-api'; import { ProjectManagers } from './utils/project-managers'; import * as webViewProviders from './web-views'; @@ -134,7 +134,7 @@ export async function activate(context: ExecutionActivationContext): Promise { return text ? (JSON.parse(text) as unknown) : undefined; } -export function getBrowseUrl(baseUrl: string, lexiconCode: string, entryId?: string): string { - let url = `${baseUrl}/paratext/fwdata/${sanitizeUrlComponent(lexiconCode)}`; - if (entryId) url += `/browse?entryId=${validateUrlComponent(entryId)}&entryOpen=true`; - return url; -} - export class FwLiteApi { // Shared across all instances (EntryService, main) — all talk to the same backend process. private static readonly projectTypeByCode = new Map(); @@ -172,6 +166,14 @@ export class FwLiteApi { /* eslint-enable no-type-assertion/no-type-assertion */ + getBrowseUrl(lexiconCode: string, entryId?: string): string { + const type = FwLiteApi.projectTypeByCode.get(lexiconCode) ?? 'FwData'; + const segment = type === 'Harmony' ? 'project' : 'fwdata'; + let url = `${this.baseUrl}/paratext/${segment}/${sanitizeUrlComponent(lexiconCode)}`; + if (entryId) url += `/browse?entryId=${validateUrlComponent(entryId)}&entryOpen=true`; + return url; + } + async createProject( name: string, code: string, From ad032f10d1e27613eff95ff2e85814a8f6ca4dc1 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 2 Jul 2026 16:51:26 -0400 Subject: [PATCH 06/20] Add en placeholder to analysis WS input Hints the default analysis writing system on the Create new lexicon form, per review feedback. Co-Authored-By: Claude Opus 4.8 (1M context) --- platform.bible-extension/src/components/create-lexicon.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/platform.bible-extension/src/components/create-lexicon.tsx b/platform.bible-extension/src/components/create-lexicon.tsx index 013dec0702..cdcb03040f 100644 --- a/platform.bible-extension/src/components/create-lexicon.tsx +++ b/platform.bible-extension/src/components/create-lexicon.tsx @@ -121,6 +121,7 @@ export default function CreateLexicon({ setAnalysisWs(e.target.value)} + placeholder="en" value={analysisWs} /> From d9996e82d623b891daa7716cd86f3746f06f31df Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 3 Jul 2026 13:43:11 -0400 Subject: [PATCH 07/20] Surface lexicon creation errors in the form Display the backend error message (e.g. duplicate code, invalid language tag) instead of only logging it, per review feedback. Also drop the now- pointless useCallback around handleSubmit. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/create-lexicon.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/platform.bible-extension/src/components/create-lexicon.tsx b/platform.bible-extension/src/components/create-lexicon.tsx index cdcb03040f..6036849fa3 100644 --- a/platform.bible-extension/src/components/create-lexicon.tsx +++ b/platform.bible-extension/src/components/create-lexicon.tsx @@ -1,7 +1,7 @@ import { logger } from '@papi/frontend'; import { useLocalizedStrings } from '@papi/frontend/react'; import { Button, Input, Label } from 'platform-bible-react'; -import { type ReactElement, useCallback, useEffect, useState } from 'react'; +import { type ReactElement, useEffect, useState } from 'react'; import { LOCALIZED_STRING_KEYS } from '../types/localized-string-keys'; const CODE_PATTERN = /^[a-z\d][a-z\d-]*$/; @@ -51,6 +51,7 @@ export default function CreateLexicon({ const [vernacularWs, setVernacularWs] = useState(defaultVernacularWs ?? ''); const [analysisWs, setAnalysisWs] = useState(''); const [creating, setCreating] = useState(false); + const [error, setError] = useState(''); useEffect(() => { if (!codeEdited) setCode(deriveCode(name)); @@ -63,18 +64,20 @@ export default function CreateLexicon({ (!analysisWs.trim() || isValidLangTag(analysisWs.trim())) ); - const handleSubmit = useCallback(async () => { + const handleSubmit = async () => { if (!isValid) return; setCreating(true); + setError(''); try { await createLexicon(name.trim(), code, vernacularWs.trim(), analysisWs.trim() || undefined); await onCreated(code); } catch (e) { logger.error(localizedStrings['%lexicon_createLexicon_error%'], JSON.stringify(e)); + setError(e instanceof Error ? e.message : String(e)); } finally { setCreating(false); } - }, [analysisWs, code, createLexicon, isValid, localizedStrings, name, onCreated, vernacularWs]); + }; return (
@@ -126,6 +129,8 @@ export default function CreateLexicon({ />
+ {error &&

{error}

} +
diff --git a/platform.bible-extension/src/main.ts b/platform.bible-extension/src/main.ts index 6146e4b599..b025321113 100644 --- a/platform.bible-extension/src/main.ts +++ b/platform.bible-extension/src/main.ts @@ -77,7 +77,10 @@ export async function activate(context: ExecutionActivationContext): Promise 0; } catch { return false; } diff --git a/platform.bible-extension/src/types/localized-string-keys.ts b/platform.bible-extension/src/types/localized-string-keys.ts index e5e44c3c28..87c6598854 100644 --- a/platform.bible-extension/src/types/localized-string-keys.ts +++ b/platform.bible-extension/src/types/localized-string-keys.ts @@ -18,6 +18,7 @@ export const LOCALIZED_STRING_KEYS: LocalizeKey[] = [ '%lexicon_createLexicon_analysisWs%', '%lexicon_createLexicon_button%', '%lexicon_createLexicon_code%', + '%lexicon_createLexicon_codeExists%', '%lexicon_createLexicon_error%', '%lexicon_createLexicon_name%', '%lexicon_createLexicon_submit%', diff --git a/platform.bible-extension/src/web-views/select-lexicon.web-view.tsx b/platform.bible-extension/src/web-views/select-lexicon.web-view.tsx index 189d6b0eef..0946d67085 100644 --- a/platform.bible-extension/src/web-views/select-lexicon.web-view.tsx +++ b/platform.bible-extension/src/web-views/select-lexicon.web-view.tsx @@ -131,6 +131,7 @@ globalThis.webViewComponent = function LexiconSelect({ l.code)} onCancel={() => setShowCreate(false)} onCreated={onCreated} /> From 057b7fbf17da0103f0dd4b345f96d1af2175593e Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 10 Jul 2026 09:18:24 -0400 Subject: [PATCH 12/20] Fix language filtering by reading wsId instead of array indices doesProjectMatchLangTag used Object.keys on the vernacular IWritingSystem[], which yields index strings ('0', '1', ...) rather than language tags, so getProjectsMatchingLanguage never matched and always fell back to all projects. Map over the array and read each writing system's wsId instead. Also drops a redundant comment on the lexicon-code validator. Co-Authored-By: Claude Opus 4.8 (1M context) --- platform.bible-extension/src/main.ts | 3 --- platform.bible-extension/src/utils/fw-lite-api.ts | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/platform.bible-extension/src/main.ts b/platform.bible-extension/src/main.ts index b025321113..f3c5c2cee6 100644 --- a/platform.bible-extension/src/main.ts +++ b/platform.bible-extension/src/main.ts @@ -77,9 +77,6 @@ export async function activate(context: ExecutionActivationContext): Promise 0; } catch { return false; diff --git a/platform.bible-extension/src/utils/fw-lite-api.ts b/platform.bible-extension/src/utils/fw-lite-api.ts index 534874be38..db38b06229 100644 --- a/platform.bible-extension/src/utils/fw-lite-api.ts +++ b/platform.bible-extension/src/utils/fw-lite-api.ts @@ -74,7 +74,7 @@ export class FwLiteApi { const tag = langTag.trim().toLocaleLowerCase().split('-')[0]; if (!code || !tag) return false; const writingSystems = await this.getWritingSystems(code); - const vernLangTags = Object.keys(writingSystems.vernacular).map((v) => v.toLocaleLowerCase()); + const vernLangTags = writingSystems.vernacular.map((ws) => ws.wsId.toLocaleLowerCase()); return vernLangTags.some((v) => v === tag || v.startsWith(`${tag}-`)); } From 4436f186104c0d13ed1033ebe4d936015593ebbb Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 13 Jul 2026 08:28:49 -0400 Subject: [PATCH 13/20] Show validation message for invalid lexicon codes Previously an invalid code (uppercase, spaces, symbols) silently disabled Submit with no feedback, unlike the codeExists case which already had a visible error. Co-Authored-By: Claude Sonnet 5 --- platform.bible-extension/contributions/localizedStrings.json | 1 + platform.bible-extension/src/components/create-lexicon.tsx | 5 +++++ platform.bible-extension/src/types/localized-string-keys.ts | 1 + 3 files changed, 7 insertions(+) diff --git a/platform.bible-extension/contributions/localizedStrings.json b/platform.bible-extension/contributions/localizedStrings.json index 2ce481ff7f..43257416eb 100644 --- a/platform.bible-extension/contributions/localizedStrings.json +++ b/platform.bible-extension/contributions/localizedStrings.json @@ -20,6 +20,7 @@ "%lexicon_createLexicon_button%": "Create new lexicon", "%lexicon_createLexicon_code%": "Code", "%lexicon_createLexicon_codeExists%": "A lexicon with this code already exists.", + "%lexicon_createLexicon_codeInvalid%": "Code must start with a lowercase letter or digit, and contain only lowercase letters, digits, and hyphens.", "%lexicon_createLexicon_error%": "Error creating lexicon:", "%lexicon_createLexicon_name%": "Name", "%lexicon_createLexicon_submit%": "Create", diff --git a/platform.bible-extension/src/components/create-lexicon.tsx b/platform.bible-extension/src/components/create-lexicon.tsx index 8f869ab3c7..d64b62df45 100644 --- a/platform.bible-extension/src/components/create-lexicon.tsx +++ b/platform.bible-extension/src/components/create-lexicon.tsx @@ -117,6 +117,11 @@ export default function CreateLexicon({ {localizedStrings['%lexicon_createLexicon_codeExists%']}

)} + {!codeExists && !!code && !CODE_PATTERN.test(code) && ( +

+ {localizedStrings['%lexicon_createLexicon_codeInvalid%']} +

+ )}
diff --git a/platform.bible-extension/src/types/localized-string-keys.ts b/platform.bible-extension/src/types/localized-string-keys.ts index 87c6598854..ce7f27b645 100644 --- a/platform.bible-extension/src/types/localized-string-keys.ts +++ b/platform.bible-extension/src/types/localized-string-keys.ts @@ -19,6 +19,7 @@ export const LOCALIZED_STRING_KEYS: LocalizeKey[] = [ '%lexicon_createLexicon_button%', '%lexicon_createLexicon_code%', '%lexicon_createLexicon_codeExists%', + '%lexicon_createLexicon_codeInvalid%', '%lexicon_createLexicon_error%', '%lexicon_createLexicon_name%', '%lexicon_createLexicon_submit%', From 5f9f128bd5c53020ab32f0181a2b3150e4dd55dc Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 13 Jul 2026 09:50:25 -0400 Subject: [PATCH 14/20] Align lexicon code validation with Lexbox's create-project rules Auto-lowercase the code as typed (matching Lexbox's create-project zod schema) instead of erroring on uppercase, and add Lexbox's 4-character minimum length with its own message. Co-Authored-By: Claude Sonnet 5 --- .../contributions/localizedStrings.json | 1 + .../src/components/create-lexicon.tsx | 15 +++++++++++---- .../src/types/localized-string-keys.ts | 1 + 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/platform.bible-extension/contributions/localizedStrings.json b/platform.bible-extension/contributions/localizedStrings.json index 43257416eb..f151a5883f 100644 --- a/platform.bible-extension/contributions/localizedStrings.json +++ b/platform.bible-extension/contributions/localizedStrings.json @@ -21,6 +21,7 @@ "%lexicon_createLexicon_code%": "Code", "%lexicon_createLexicon_codeExists%": "A lexicon with this code already exists.", "%lexicon_createLexicon_codeInvalid%": "Code must start with a lowercase letter or digit, and contain only lowercase letters, digits, and hyphens.", + "%lexicon_createLexicon_codeTooShort%": "Code must be at least 4 characters.", "%lexicon_createLexicon_error%": "Error creating lexicon:", "%lexicon_createLexicon_name%": "Name", "%lexicon_createLexicon_submit%": "Create", diff --git a/platform.bible-extension/src/components/create-lexicon.tsx b/platform.bible-extension/src/components/create-lexicon.tsx index d64b62df45..78d37ca0ad 100644 --- a/platform.bible-extension/src/components/create-lexicon.tsx +++ b/platform.bible-extension/src/components/create-lexicon.tsx @@ -5,6 +5,7 @@ import { type ReactElement, useEffect, useState } from 'react'; import { LOCALIZED_STRING_KEYS } from '../types/localized-string-keys'; const CODE_PATTERN = /^[a-z\d][a-z\d-]*$/; +const MIN_CODE_LENGTH = 4; function isValidLangTag(tag: string): boolean { try { @@ -59,13 +60,14 @@ export default function CreateLexicon({ if (!codeEdited) setCode(deriveCode(name)); }, [codeEdited, name]); - // Codes are lowercased by CODE_PATTERN and on the backend, but compare case-insensitively - // so a well-formed collision is caught before the create round-trips and 400s. + // The code input lowercases as typed, but existingCodes may come from elsewhere with different + // casing, so compare case-insensitively to catch a well-formed collision before it 400s. const codeExists = !!code && (existingCodes ?? []).some((c) => c.toLowerCase() === code.toLowerCase()); const isValid = !!( name.trim() && + code.length >= MIN_CODE_LENGTH && CODE_PATTERN.test(code) && !codeExists && isValidLangTag(vernacularWs.trim()) && @@ -107,7 +109,7 @@ export default function CreateLexicon({ { - setCode(e.target.value); + setCode(e.target.value.toLowerCase()); setCodeEdited(true); }} value={code} @@ -117,7 +119,12 @@ export default function CreateLexicon({ {localizedStrings['%lexicon_createLexicon_codeExists%']}

)} - {!codeExists && !!code && !CODE_PATTERN.test(code) && ( + {!codeExists && !!code && code.length < MIN_CODE_LENGTH && ( +

+ {localizedStrings['%lexicon_createLexicon_codeTooShort%']} +

+ )} + {!codeExists && code.length >= MIN_CODE_LENGTH && !CODE_PATTERN.test(code) && (

{localizedStrings['%lexicon_createLexicon_codeInvalid%']}

diff --git a/platform.bible-extension/src/types/localized-string-keys.ts b/platform.bible-extension/src/types/localized-string-keys.ts index ce7f27b645..d2fcec96f8 100644 --- a/platform.bible-extension/src/types/localized-string-keys.ts +++ b/platform.bible-extension/src/types/localized-string-keys.ts @@ -20,6 +20,7 @@ export const LOCALIZED_STRING_KEYS: LocalizeKey[] = [ '%lexicon_createLexicon_code%', '%lexicon_createLexicon_codeExists%', '%lexicon_createLexicon_codeInvalid%', + '%lexicon_createLexicon_codeTooShort%', '%lexicon_createLexicon_error%', '%lexicon_createLexicon_name%', '%lexicon_createLexicon_submit%', From 75121ffd229288a0dbd258c16383083624e22a39 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 21 Jul 2026 14:46:57 -0400 Subject: [PATCH 15/20] Add lang-tag validation and recover from a deleted lexicon Addresses later review feedback on #2394: - Show a validation message for an invalid vernacular or analysis language tag in the create-lexicon form; previously the Create button was disabled with no explanation (per myieye). - Re-check a project's stored lexicon code before acting on it; if it no longer resolves (e.g. deleted in FW Lite) clear it and reopen the selector instead of routing Browse/Add/Find to a dead endpoint (per alex-rawlings-yyc). - Add a DEV-ONLY lexicon switcher (lexicon.changeLexicon menu command) so the selector is reachable after a lexicon is chosen; marked for removal before release. Canonical switch remains clearing lexicon.lexiconCode in project settings. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../contributions/localizedStrings.json | 2 + .../contributions/menus.json | 6 +++ .../src/components/create-lexicon.tsx | 10 +++++ platform.bible-extension/src/main.ts | 40 ++++++++++++++++--- .../src/types/lexicon.d.ts | 2 + .../src/types/localized-string-keys.ts | 1 + .../src/utils/project-manager.ts | 26 +++++++++--- .../src/utils/project-managers.ts | 7 +++- 8 files changed, 82 insertions(+), 12 deletions(-) diff --git a/platform.bible-extension/contributions/localizedStrings.json b/platform.bible-extension/contributions/localizedStrings.json index f151a5883f..988031e6d1 100644 --- a/platform.bible-extension/contributions/localizedStrings.json +++ b/platform.bible-extension/contributions/localizedStrings.json @@ -23,6 +23,7 @@ "%lexicon_createLexicon_codeInvalid%": "Code must start with a lowercase letter or digit, and contain only lowercase letters, digits, and hyphens.", "%lexicon_createLexicon_codeTooShort%": "Code must be at least 4 characters.", "%lexicon_createLexicon_error%": "Error creating lexicon:", + "%lexicon_createLexicon_langTagInvalid%": "Not a valid IETF language tag.", "%lexicon_createLexicon_name%": "Name", "%lexicon_createLexicon_submit%": "Create", "%lexicon_createLexicon_title%": "Create new lexicon", @@ -46,6 +47,7 @@ "%lexicon_menu_browseLexicon%": "Browse lexicon", "%lexicon_menu_findEntry%": "Search in lexicon...", "%lexicon_menu_findRelatedEntries%": "Search for related words...", + "%lexicon_menu_selectLexicon%": "Select lexicon...", "%lexicon_projectSettings_analysisLanguage%": "Analysis language", "%lexicon_projectSettings_lexicon%": "Lexicon", "%lexicon_projectSettings_lexiconDescription%": "The lexicon to use with this project", diff --git a/platform.bible-extension/contributions/menus.json b/platform.bible-extension/contributions/menus.json index 732ba595fd..77e4c63663 100644 --- a/platform.bible-extension/contributions/menus.json +++ b/platform.bible-extension/contributions/menus.json @@ -47,6 +47,12 @@ "group": "lexicon.editor", "order": 4, "command": "lexicon.findRelatedEntries" + }, + { + "label": "%lexicon_menu_selectLexicon%", + "group": "lexicon.editor", + "order": 5, + "command": "lexicon.changeLexicon" } ] } diff --git a/platform.bible-extension/src/components/create-lexicon.tsx b/platform.bible-extension/src/components/create-lexicon.tsx index 78d37ca0ad..79a3773dac 100644 --- a/platform.bible-extension/src/components/create-lexicon.tsx +++ b/platform.bible-extension/src/components/create-lexicon.tsx @@ -140,6 +140,11 @@ export default function CreateLexicon({ onChange={(e) => setVernacularWs(e.target.value)} value={vernacularWs} /> + {!!vernacularWs.trim() && !isValidLangTag(vernacularWs.trim()) && ( +

+ {localizedStrings['%lexicon_createLexicon_langTagInvalid%']} +

+ )}
@@ -152,6 +157,11 @@ export default function CreateLexicon({ placeholder="en" value={analysisWs} /> + {!!analysisWs.trim() && !isValidLangTag(analysisWs.trim()) && ( +

+ {localizedStrings['%lexicon_createLexicon_langTagInvalid%']} +

+ )}
{error &&

{error}

} diff --git a/platform.bible-extension/src/main.ts b/platform.bible-extension/src/main.ts index f3c5c2cee6..b6299166c4 100644 --- a/platform.bible-extension/src/main.ts +++ b/platform.bible-extension/src/main.ts @@ -61,6 +61,17 @@ export async function activate(context: ExecutionActivationContext): Promise => { + try { + return (await fwLiteApi.getWritingSystems(lexiconCode)).vernacular.length > 0; + } catch { + return false; + } + }; + /* Register settings validators */ const validateAnalysisLanguage = papi.projectSettings.registerValidator( @@ -76,17 +87,13 @@ export async function activate(context: ExecutionActivationContext): Promise 0; - } catch { - return false; - } + return isLexiconCodeValid(newValue); }, ); /* Manage project info and WebViews */ - const projectManagers = new ProjectManagers(); + const projectManagers = new ProjectManagers(isLexiconCodeValid); /* Register commands */ @@ -256,6 +263,26 @@ export async function activate(context: ExecutionActivationContext): Promise { + const projectManager = + await projectManagers.getProjectManagerFromWebViewIdOrSelectProject(webViewId); + if (!projectManager) return { success: false }; + const success = await projectManager.openSelector(); + return { success }; + }, + ); + const createLexiconCommandPromise = papi.commands.registerCommand( 'lexicon.createLexicon', async (name: string, code: string, vernacularWs: string, analysisWs?: string) => { @@ -298,6 +325,7 @@ export async function activate(context: ExecutionActivationContext): Promise Promise; 'lexicon.authServers': () => Promise; 'lexicon.browseLexicon': (webViewId: string) => Promise; + /** DEV-ONLY lexicon switcher; remove before release (see src/main.ts changeLexiconCommand). */ + 'lexicon.changeLexicon': (webViewId: string) => Promise; 'lexicon.createLexicon': ( name: string, code: string, diff --git a/platform.bible-extension/src/types/localized-string-keys.ts b/platform.bible-extension/src/types/localized-string-keys.ts index d2fcec96f8..98c30efd58 100644 --- a/platform.bible-extension/src/types/localized-string-keys.ts +++ b/platform.bible-extension/src/types/localized-string-keys.ts @@ -22,6 +22,7 @@ export const LOCALIZED_STRING_KEYS: LocalizeKey[] = [ '%lexicon_createLexicon_codeInvalid%', '%lexicon_createLexicon_codeTooShort%', '%lexicon_createLexicon_error%', + '%lexicon_createLexicon_langTagInvalid%', '%lexicon_createLexicon_name%', '%lexicon_createLexicon_submit%', '%lexicon_createLexicon_title%', diff --git a/platform.bible-extension/src/utils/project-manager.ts b/platform.bible-extension/src/utils/project-manager.ts index 5091d97982..7a4be04676 100644 --- a/platform.bible-extension/src/utils/project-manager.ts +++ b/platform.bible-extension/src/utils/project-manager.ts @@ -10,9 +10,11 @@ export class ProjectManager { readonly projectId: string; private dataProvider?: IBaseProjectDataProvider; private readonly webViewIds: WebViewIds = {}; + private readonly isLexiconCodeValid: (lexiconCode: string) => Promise; - constructor(projectId: string) { + constructor(projectId: string, isLexiconCodeValid?: (lexiconCode: string) => Promise) { this.projectId = projectId; + this.isLexiconCodeValid = isLexiconCodeValid ?? (async () => true); } static async getLexiconCode(projectId: string): Promise { @@ -36,14 +38,28 @@ export class ProjectManager { const lexiconCode = await this.getSetting(ProjectSettingKey.LexiconCode); const nameOrId = await this.getNameOrId(); if (lexiconCode) { - logger.info(`Project '${nameOrId}' is using lexicon '${lexiconCode}'`); - return lexiconCode; + if (await this.isLexiconCodeValid(lexiconCode)) { + logger.info(`Project '${nameOrId}' is using lexicon '${lexiconCode}'`); + return lexiconCode; + } + // The stored lexicon no longer resolves (e.g. it was deleted in FW Lite). Clear it so the + // project isn't stuck pointing at a missing lexicon — otherwise every action would open a + // broken view — then fall through to prompt for a new selection. + logger.warn( + `Lexicon '${lexiconCode}' for project '${nameOrId}' no longer resolves; clearing`, + ); + await this.setLexiconCode(''); + } else { + logger.info(`Lexicon not yet selected for project '${nameOrId}'`); } - logger.info(`Lexicon not yet selected for project '${nameOrId}'`); + await this.openSelector(); + } + + async openSelector(): Promise { const vernacularLanguage = await this.getLanguageTag(); const options: LexiconWebViewOptions = { vernacularLanguage }; - await this.openWebView( + return await this.openWebView( WebViewType.SelectLexicon, { floatSize: { height: 500, width: 400 }, type: 'float' }, options, diff --git a/platform.bible-extension/src/utils/project-managers.ts b/platform.bible-extension/src/utils/project-managers.ts index e635d78f30..5bd147e753 100644 --- a/platform.bible-extension/src/utils/project-managers.ts +++ b/platform.bible-extension/src/utils/project-managers.ts @@ -3,6 +3,11 @@ import { ProjectManager } from './project-manager'; export class ProjectManagers { private readonly projectManagers: { [projectId: string]: ProjectManager } = {}; + private readonly isLexiconCodeValid: (lexiconCode: string) => Promise; + + constructor(isLexiconCodeValid: (lexiconCode: string) => Promise) { + this.isLexiconCodeValid = isLexiconCodeValid; + } static async getProjectIdFromWebViewId(webViewId: string): Promise { if (!webViewId) return; @@ -19,7 +24,7 @@ export class ProjectManagers { getProjectManagerFromProjectId(projectId: string): ProjectManager | undefined { if (!projectId) return; if (!(projectId in this.projectManagers)) { - this.projectManagers[projectId] = new ProjectManager(projectId); + this.projectManagers[projectId] = new ProjectManager(projectId, this.isLexiconCodeValid); } return this.projectManagers[projectId]; } From 09df8f086ceb51fc81449c22fef9f84dee406540 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 21 Jul 2026 15:44:49 -0400 Subject: [PATCH 16/20] Tweak comments --- .../src/components/create-lexicon.tsx | 2 +- platform.bible-extension/src/main.ts | 14 +++++++------- platform.bible-extension/src/utils/fw-lite-api.ts | 6 +++--- .../src/utils/project-manager.ts | 4 ++-- .../src/web-views/select-lexicon.web-view.tsx | 3 +-- 5 files changed, 14 insertions(+), 15 deletions(-) diff --git a/platform.bible-extension/src/components/create-lexicon.tsx b/platform.bible-extension/src/components/create-lexicon.tsx index 79a3773dac..fedcecc769 100644 --- a/platform.bible-extension/src/components/create-lexicon.tsx +++ b/platform.bible-extension/src/components/create-lexicon.tsx @@ -5,7 +5,7 @@ import { type ReactElement, useEffect, useState } from 'react'; import { LOCALIZED_STRING_KEYS } from '../types/localized-string-keys'; const CODE_PATTERN = /^[a-z\d][a-z\d-]*$/; -const MIN_CODE_LENGTH = 4; +const MIN_CODE_LENGTH = 4; // matches Lexbox function isValidLangTag(tag: string): boolean { try { diff --git a/platform.bible-extension/src/main.ts b/platform.bible-extension/src/main.ts index b6299166c4..1418f9396e 100644 --- a/platform.bible-extension/src/main.ts +++ b/platform.bible-extension/src/main.ts @@ -264,14 +264,14 @@ export async function activate(context: ExecutionActivationContext): Promise { diff --git a/platform.bible-extension/src/utils/fw-lite-api.ts b/platform.bible-extension/src/utils/fw-lite-api.ts index db38b06229..b23135b61c 100644 --- a/platform.bible-extension/src/utils/fw-lite-api.ts +++ b/platform.bible-extension/src/utils/fw-lite-api.ts @@ -195,9 +195,9 @@ export class FwLiteApi { /** * Looks up a project's API type. The cache is in-memory only and empty after an extension - * restart, so on a miss we repopulate it from the backend; otherwise a previously created - * Harmony/CRDT project would be misrouted to the FwData endpoints and every operation on it would - * fail. Falls back to 'FwData' if the type still can't be determined. + * restart, so on a miss we repopulate it from the backend; else a Harmony/CRDT project could be + * misrouted to the FwData endpoints and every operation on it would fail. Falls back to 'FwData' + * if the type can't be determined. */ private async resolveProjectType(code: string): Promise<'FwData' | 'Harmony'> { const cached = FwLiteApi.projectTypeByCode.get(code); diff --git a/platform.bible-extension/src/utils/project-manager.ts b/platform.bible-extension/src/utils/project-manager.ts index 7a4be04676..034394d76d 100644 --- a/platform.bible-extension/src/utils/project-manager.ts +++ b/platform.bible-extension/src/utils/project-manager.ts @@ -43,8 +43,8 @@ export class ProjectManager { return lexiconCode; } // The stored lexicon no longer resolves (e.g. it was deleted in FW Lite). Clear it so the - // project isn't stuck pointing at a missing lexicon — otherwise every action would open a - // broken view — then fall through to prompt for a new selection. + // project isn't stuck pointing at a missing lexicon (which would cause every action to open a + // broken view), then fall through to prompt for a new selection. logger.warn( `Lexicon '${lexiconCode}' for project '${nameOrId}' no longer resolves; clearing`, ); diff --git a/platform.bible-extension/src/web-views/select-lexicon.web-view.tsx b/platform.bible-extension/src/web-views/select-lexicon.web-view.tsx index 0946d67085..68726057f2 100644 --- a/platform.bible-extension/src/web-views/select-lexicon.web-view.tsx +++ b/platform.bible-extension/src/web-views/select-lexicon.web-view.tsx @@ -108,8 +108,7 @@ globalThis.webViewComponent = function LexiconSelect({ await selectLexicon(code); setDone(true); } catch (e) { - // Lexicon was created but auto-selection failed; go back to the combo box so - // the user can select it manually from the refreshed list. + // Lexicon created but auto-selection failed; return to the project-selection combo box. logger.error('Error auto-selecting created lexicon:', JSON.stringify(e)); fetchLexicons(); setShowCreate(false); From f212c51c2be23916bbe7c708aed5bbb37794bb7f Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 21 Jul 2026 16:49:13 -0400 Subject: [PATCH 17/20] Tighten lexicon create validation comments --- .../src/components/create-lexicon.tsx | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/platform.bible-extension/src/components/create-lexicon.tsx b/platform.bible-extension/src/components/create-lexicon.tsx index fedcecc769..7a1a74659e 100644 --- a/platform.bible-extension/src/components/create-lexicon.tsx +++ b/platform.bible-extension/src/components/create-lexicon.tsx @@ -5,13 +5,24 @@ import { type ReactElement, useEffect, useState } from 'react'; import { LOCALIZED_STRING_KEYS } from '../types/localized-string-keys'; const CODE_PATTERN = /^[a-z\d][a-z\d-]*$/; -const MIN_CODE_LENGTH = 4; // matches Lexbox +// Matches Lexbox UI validation: frontend/src/routes/(authenticated)/project/create/+page.svelte +const MIN_CODE_LENGTH = 4; +// Loosely approximates FW Lite backend validation via SIL.WritingSystems.IetfLanguageTag.IsValid +const BASIC_IETF_LANGUAGE_TAG_PATTERN = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/; + +function normalizeLangTag(tag: string): string { + return tag.trim().replace(/_/g, '-'); +} function isValidLangTag(tag: string): boolean { + const normalizedTag = normalizeLangTag(tag); + if (!normalizedTag) return false; + try { - return Intl.getCanonicalLocales(tag).length === 1; + return Intl.getCanonicalLocales(normalizedTag).length === 1; } catch { - return false; + // Fallback to syntax validation so we don't block backend-accepted tags that Intl rejects. + return BASIC_IETF_LANGUAGE_TAG_PATTERN.test(normalizedTag); } } @@ -70,8 +81,8 @@ export default function CreateLexicon({ code.length >= MIN_CODE_LENGTH && CODE_PATTERN.test(code) && !codeExists && - isValidLangTag(vernacularWs.trim()) && - (!analysisWs.trim() || isValidLangTag(analysisWs.trim())) + isValidLangTag(vernacularWs) && + (!analysisWs.trim() || isValidLangTag(analysisWs)) ); const handleSubmit = async () => { @@ -79,7 +90,12 @@ export default function CreateLexicon({ setCreating(true); setError(''); try { - await createLexicon(name.trim(), code, vernacularWs.trim(), analysisWs.trim() || undefined); + await createLexicon( + name.trim(), + code, + normalizeLangTag(vernacularWs), + analysisWs.trim() ? normalizeLangTag(analysisWs) : undefined, + ); await onCreated(code); } catch (e) { logger.error(localizedStrings['%lexicon_createLexicon_error%'], JSON.stringify(e)); @@ -140,7 +156,7 @@ export default function CreateLexicon({ onChange={(e) => setVernacularWs(e.target.value)} value={vernacularWs} /> - {!!vernacularWs.trim() && !isValidLangTag(vernacularWs.trim()) && ( + {!!vernacularWs.trim() && !isValidLangTag(vernacularWs) && (

{localizedStrings['%lexicon_createLexicon_langTagInvalid%']}

@@ -157,7 +173,7 @@ export default function CreateLexicon({ placeholder="en" value={analysisWs} /> - {!!analysisWs.trim() && !isValidLangTag(analysisWs.trim()) && ( + {!!analysisWs.trim() && !isValidLangTag(analysisWs) && (

{localizedStrings['%lexicon_createLexicon_langTagInvalid%']}

From 6a407bae9afa0b5a4743aabc55be17d13805ff91 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 24 Jul 2026 11:18:43 -0400 Subject: [PATCH 18/20] Don't treat a transient backend failure as a confirmed lexicon deletion isLexiconCodeValid and resolveProjectType previously collapsed every error into "the lexicon is gone" / "default to FwData", so FW Lite still starting up (or any other transient failure) could silently clear a project's stored lexicon choice or misroute a Harmony project. Add HttpStatusError to distinguish a real backend response from a request that never reached it at all, and only treat the former as confirmed-invalid. resolveProjectType now propagates a getProjects failure instead of guessing, since that endpoint has no per-code failure mode to misinterpret. Full correctness (narrowing to a 404-specific check) is blocked on sillsdev/languageforge-lexbox#2487, which tracks the backend currently returning 500 for "not found" too. Co-Authored-By: Claude Sonnet 5 --- platform.bible-extension/src/main.ts | 39 +++++++++++++++++-- .../src/utils/fw-lite-api.ts | 21 +++++----- .../src/utils/http-status-error.ts | 14 +++++++ 3 files changed, 59 insertions(+), 15 deletions(-) create mode 100644 platform.bible-extension/src/utils/http-status-error.ts diff --git a/platform.bible-extension/src/main.ts b/platform.bible-extension/src/main.ts index 1418f9396e..9f7be1a31e 100644 --- a/platform.bible-extension/src/main.ts +++ b/platform.bible-extension/src/main.ts @@ -7,6 +7,7 @@ import { Stream } from 'stream'; import { EntryService } from './services/entry-service'; import { WebViewType } from './types/enums'; import { FwLiteApi, type LoginResult } from './utils/fw-lite-api'; +import { HttpStatusError } from './utils/http-status-error'; import { ProjectManagers } from './utils/project-managers'; import * as webViewProviders from './web-views'; @@ -64,11 +65,23 @@ export async function activate(context: ExecutionActivationContext): Promise => { try { return (await fwLiteApi.getWritingSystems(lexiconCode)).vernacular.length > 0; - } catch { - return false; + } catch (e) { + if (e instanceof HttpStatusError) return false; + logger.warn( + `Could not verify lexicon '${lexiconCode}'; backend may be unreachable:`, + getErrorMessage(e), + ); + return true; } }; @@ -141,7 +154,16 @@ export async function activate(context: ExecutionActivationContext): Promise { const results = await papi.fetch(input, init); if (!results.ok) { const errorBody = await results.text(); - throw new Error(errorBody || `Failed to fetch: ${results.status} ${results.statusText}`); + throw new HttpStatusError( + results.status, + errorBody || `Failed to fetch: ${results.status} ${results.statusText}`, + ); } const text = await results.text(); // eslint-disable-next-line no-type-assertion/no-type-assertion @@ -196,20 +200,15 @@ export class FwLiteApi { /** * Looks up a project's API type. The cache is in-memory only and empty after an extension * restart, so on a miss we repopulate it from the backend; else a Harmony/CRDT project could be - * misrouted to the FwData endpoints and every operation on it would fail. Falls back to 'FwData' - * if the type can't be determined. + * misrouted to the FwData endpoints and every operation on it would fail. `getProjects` has no + * per-code failure mode (it just enumerates), so if it throws, the type genuinely can't be + * determined; propagate the failure instead of guessing 'FwData' and misrouting a Harmony + * project. Only defaults to 'FwData' when the backend answered but the code is unrecognized. */ private async resolveProjectType(code: string): Promise<'FwData' | 'Harmony'> { const cached = FwLiteApi.projectTypeByCode.get(code); if (cached) return cached; - try { - await this.getProjects(); - } catch (e) { - logger.warn( - 'Could not load project types; defaulting to FwData:', - e instanceof Error ? e.message : String(e), - ); - } + await this.getProjects(); return FwLiteApi.projectTypeByCode.get(code) ?? 'FwData'; } diff --git a/platform.bible-extension/src/utils/http-status-error.ts b/platform.bible-extension/src/utils/http-status-error.ts new file mode 100644 index 0000000000..39ecef6765 --- /dev/null +++ b/platform.bible-extension/src/utils/http-status-error.ts @@ -0,0 +1,14 @@ +/** + * Thrown when the backend actually responded with a non-OK status, as opposed to the request + * failing to reach it at all (e.g. connection refused while FW Lite is still starting up). Callers + * can use this distinction to avoid treating a transient/unreachable failure the same as a + * definitive backend response. + */ +export class HttpStatusError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + } +} From 258add7c995408438ad42813c1b7e89fc1f56f6d Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 24 Jul 2026 11:52:59 -0400 Subject: [PATCH 19/20] Unwrap JSON error bodies and stop one bad project from killing language filtering Backend error responses are JSON either way (a bare string from Results.BadRequest/NotFound, or a ProblemDetails object from Results.Problem), but fetchUrl surfaced the raw response text verbatim, so create-lexicon validation errors reached the user still wrapped in JSON-string quotes. extractErrorMessage unwraps either shape. getProjectsMatchingLanguage used Promise.all, so one project's failed writingSystems lookup (e.g. a broken/deleted project) rejected the whole batch and silently fell back to the unfiltered project list. Switch to Promise.allSettled so a single failure just drops that project instead of discarding every other project's match. Co-Authored-By: Claude Sonnet 5 --- .../src/utils/fw-lite-api.ts | 62 +++++++++++++++---- 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/platform.bible-extension/src/utils/fw-lite-api.ts b/platform.bible-extension/src/utils/fw-lite-api.ts index 1de68a2e0f..e22307f975 100644 --- a/platform.bible-extension/src/utils/fw-lite-api.ts +++ b/platform.bible-extension/src/utils/fw-lite-api.ts @@ -12,6 +12,7 @@ import type { IServerStatus, LoginResult as GeneratedLoginResult, } from '@dotnet-types'; +import { getErrorMessage } from 'platform-bible-utils'; import { GridifyConditionalOperator } from '../types/enums'; import { HttpStatusError } from './http-status-error'; @@ -35,6 +36,30 @@ function validateUrlComponent(urlComponent?: string): string { return urlComponent; } +/** + * Backend error bodies are JSON: either a bare string or a `ProblemDetails` object with a `detail` + * field. Unwrap either shape to plain text so it doesn't reach the user still wrapped in + * JSON-string quotes; anything else, including invalid JSON, is returned as-is. + */ +function extractErrorMessage(body: string): string { + if (!body) return body; + try { + const parsed: unknown = JSON.parse(body); + if (typeof parsed === 'string') return parsed; + if ( + parsed && + typeof parsed === 'object' && + 'detail' in parsed && + typeof parsed.detail === 'string' + ) { + return parsed.detail; + } + } catch { + // Not JSON — fall through and use the raw text. + } + return body; +} + async function fetchUrl(input: string, init?: RequestInit): Promise { logger.info(`About to fetch: ${input}`); if (init) { @@ -45,7 +70,7 @@ async function fetchUrl(input: string, init?: RequestInit): Promise { const errorBody = await results.text(); throw new HttpStatusError( results.status, - errorBody || `Failed to fetch: ${results.status} ${results.statusText}`, + extractErrorMessage(errorBody) || `Failed to fetch: ${results.status} ${results.statusText}`, ); } const text = await results.text(); @@ -121,18 +146,29 @@ export class FwLiteApi { const projects = await this.getProjects(); if (!langTag?.trim()) return projects; - try { - const matches = ( - await Promise.all( - projects.map(async (p) => - (await this.doesProjectMatchLangTag(p.code, langTag)) ? p : undefined, - ), - ) - ).filter((p) => p) as IProjectModel[]; - return matches.length ? matches : projects; - } catch { - return projects; - } + // Promise.allSettled so one project's failed check (e.g. a broken/deleted project) only + // drops that project from consideration. + const results = await Promise.allSettled( + projects.map(async (p) => + (await this.doesProjectMatchLangTag(p.code, langTag)) ? p : undefined, + ), + ); + results.forEach((result, i) => { + if (result.status === 'rejected') { + logger.warn( + `Could not check language match for project '${projects[i].code}':`, + getErrorMessage(result.reason), + ); + } + }); + const matches = results + .filter( + (result): result is PromiseFulfilledResult => + result.status === 'fulfilled', + ) + .map((result) => result.value) + .filter((p): p is IProjectModel => Boolean(p)); + return matches.length ? matches : projects; } async getWritingSystems(lexiconCode?: string): Promise { From 91c442be4ea6733ced99bf5a502fb30ac57a1734 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 24 Jul 2026 12:10:22 -0400 Subject: [PATCH 20/20] Surface lexicon-selection save failures in the combo box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit saveSetting caught a failed selectLexicon call, logged it, and cleared settingSaving without any visible sign of failure — the UI just silently returned to the combo box. Add a saveError state and render it next to the combo box, reusing the existing (previously log-only) %lexicon_selectLexicon_saveError% string's intent. Co-Authored-By: Claude Sonnet 5 --- .../src/components/lexicon-combo-box.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/platform.bible-extension/src/components/lexicon-combo-box.tsx b/platform.bible-extension/src/components/lexicon-combo-box.tsx index f13f146c37..d33e00cbfc 100644 --- a/platform.bible-extension/src/components/lexicon-combo-box.tsx +++ b/platform.bible-extension/src/components/lexicon-combo-box.tsx @@ -20,6 +20,7 @@ export default function LexiconComboBox({ }: LexiconComboBoxProps): ReactElement { const [localizedStrings] = useLocalizedStrings(LOCALIZED_STRING_KEYS); + const [saveError, setSaveError] = useState(''); const [selectedLexiconCode, setSelectedLexiconCode] = useState(''); const [settingSaved, setSettingSaved] = useState(false); const [settingSaving, setSettingSaving] = useState(false); @@ -27,13 +28,15 @@ export default function LexiconComboBox({ const saveSetting = useCallback( (code: string): void => { if (!code) return; + setSaveError(''); setSettingSaving(true); // eslint-disable-next-line promise/catch-or-return selectLexicon(code) .then(() => setSettingSaved(true)) - .catch((e) => - logger.error(localizedStrings['%lexicon_selectLexicon_saveError%'], JSON.stringify(e)), - ) + .catch((e) => { + logger.error(localizedStrings['%lexicon_selectLexicon_saveError%'], JSON.stringify(e)); + setSaveError(e instanceof Error ? e.message : String(e)); + }) .finally(() => setSettingSaving(false)); }, [localizedStrings, selectLexicon], @@ -76,6 +79,8 @@ export default function LexiconComboBox({ textPlaceholder={localizedStrings['%lexicon_selectLexicon_select%']} /> + {!!saveError &&

{saveError}

} + {!!selectedLexiconCode && (