diff --git a/platform.bible-extension/contributions/localizedStrings.json b/platform.bible-extension/contributions/localizedStrings.json index 8e4699ff7d..988031e6d1 100644 --- a/platform.bible-extension/contributions/localizedStrings.json +++ b/platform.bible-extension/contributions/localizedStrings.json @@ -5,8 +5,8 @@ "%lexicon_addWord_buttonAdd%": "Add new entry", "%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:", @@ -16,6 +16,18 @@ "%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_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_langTagInvalid%": "Not a valid IETF language tag.", + "%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", @@ -35,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 new file mode 100644 index 0000000000..7a1a74659e --- /dev/null +++ b/platform.bible-extension/src/components/create-lexicon.tsx @@ -0,0 +1,195 @@ +import { logger } from '@papi/frontend'; +import { useLocalizedStrings } from '@papi/frontend/react'; +import { Button, Input, Label } from 'platform-bible-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-]*$/; +// 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(normalizedTag).length === 1; + } catch { + // Fallback to syntax validation so we don't block backend-accepted tags that Intl rejects. + return BASIC_IETF_LANGUAGE_TAG_PATTERN.test(normalizedTag); + } +} + +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; + existingCodes?: 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, + existingCodes, + 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); + const [error, setError] = useState(''); + + useEffect(() => { + if (!codeEdited) setCode(deriveCode(name)); + }, [codeEdited, name]); + + // 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) && + (!analysisWs.trim() || isValidLangTag(analysisWs)) + ); + + const handleSubmit = async () => { + if (!isValid) return; + setCreating(true); + setError(''); + try { + 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)); + setError(e instanceof Error ? e.message : String(e)); + } finally { + setCreating(false); + } + }; + + return ( +
+

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

+ +
+ + setName(e.target.value)} value={name} /> +
+ +
+ + { + setCode(e.target.value.toLowerCase()); + setCodeEdited(true); + }} + value={code} + /> + {codeExists && ( +

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

+ )} + {!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%']} +

+ )} +
+ +
+ + setVernacularWs(e.target.value)} + value={vernacularWs} + /> + {!!vernacularWs.trim() && !isValidLangTag(vernacularWs) && ( +

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

+ )} +
+ +
+ + setAnalysisWs(e.target.value)} + placeholder="en" + value={analysisWs} + /> + {!!analysisWs.trim() && !isValidLangTag(analysisWs) && ( +

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

+ )} +
+ + {error &&

{error}

} + +
+ + +
+
+ ); +} diff --git a/platform.bible-extension/src/components/lexicon-combo-box.tsx b/platform.bible-extension/src/components/lexicon-combo-box.tsx index cc8ce37b52..d33e00cbfc 100644 --- a/platform.bible-extension/src/components/lexicon-combo-box.tsx +++ b/platform.bible-extension/src/components/lexicon-combo-box.tsx @@ -8,16 +8,19 @@ 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); + const [saveError, setSaveError] = useState(''); const [selectedLexiconCode, setSelectedLexiconCode] = useState(''); const [settingSaved, setSettingSaved] = useState(false); const [settingSaving, setSettingSaving] = useState(false); @@ -25,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], @@ -74,6 +79,8 @@ export default function LexiconComboBox({ textPlaceholder={localizedStrings['%lexicon_selectLexicon_select%']} /> + {!!saveError &&

{saveError}

} + {!!selectedLexiconCode && (
)} + + {!!onCreateNew && ( + + )} ); } diff --git a/platform.bible-extension/src/main.ts b/platform.bible-extension/src/main.ts index 6dccfd177b..9f7be1a31e 100644 --- a/platform.bible-extension/src/main.ts +++ b/platform.bible-extension/src/main.ts @@ -6,7 +6,8 @@ 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 { HttpStatusError } from './utils/http-status-error'; import { ProjectManagers } from './utils/project-managers'; import * as webViewProviders from './web-views'; @@ -61,6 +62,29 @@ export async function activate(context: ExecutionActivationContext): Promise => { + try { + return (await fwLiteApi.getWritingSystems(lexiconCode)).vernacular.length > 0; + } catch (e) { + if (e instanceof HttpStatusError) return false; + logger.warn( + `Could not verify lexicon '${lexiconCode}'; backend may be unreachable:`, + getErrorMessage(e), + ); + return true; + } + }; + /* Register settings validators */ const validateAnalysisLanguage = papi.projectSettings.registerValidator( @@ -76,17 +100,13 @@ 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) => { + try { + await fwLiteApi.createProject(name, code, vernacularWs, analysisWs); + return { success: true }; + } catch (e) { + const error = e instanceof Error ? e.message : String(e); + logger.error('Error creating lexicon:', error); + return { success: false, error }; + } + }, + ); + const lexiconsCommandPromise = papi.commands.registerCommand( 'lexicon.lexicons', async (projectId?: string) => { @@ -284,6 +356,8 @@ 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, + 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..98c30efd58 100644 --- a/platform.bible-extension/src/types/localized-string-keys.ts +++ b/platform.bible-extension/src/types/localized-string-keys.ts @@ -4,8 +4,8 @@ export const LOCALIZED_STRING_KEYS: LocalizeKey[] = [ '%lexicon_addWord_buttonAdd%', '%lexicon_addWord_buttonSubmit%', '%lexicon_addWord_title%', - '%lexicon_auth_login%', '%lexicon_auth_loggingIn%', + '%lexicon_auth_login%', '%lexicon_auth_loginError%', '%lexicon_auth_logout%', '%lexicon_auth_logoutError%', @@ -15,6 +15,18 @@ 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_codeExists%', + '%lexicon_createLexicon_codeInvalid%', + '%lexicon_createLexicon_codeTooShort%', + '%lexicon_createLexicon_error%', + '%lexicon_createLexicon_langTagInvalid%', + '%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 11baf67bbf..e22307f975 100644 --- a/platform.bible-extension/src/utils/fw-lite-api.ts +++ b/platform.bible-extension/src/utils/fw-lite-api.ts @@ -12,7 +12,9 @@ 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'; // Local aliases for the FW Lite backend's generated API types (type-only via @dotnet-types). export type LexboxServer = ILexboxServer; @@ -34,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) { @@ -41,18 +67,21 @@ 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 HttpStatusError( + results.status, + extractErrorMessage(errorBody) || `Failed to fetch: ${results.status} ${results.statusText}`, + ); } - return await results.json(); -} - -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; + const text = await results.text(); + // eslint-disable-next-line no-type-assertion/no-type-assertion + return text ? (JSON.parse(text) as unknown) : undefined; } 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) { @@ -65,7 +94,7 @@ export class FwLiteApi { } async deleteEntry(id: string, lexiconCode?: string): Promise { - const { code, type } = this.checkLexiconCode(lexiconCode); + const { code, type } = await this.checkLexiconCode(lexiconCode); const path = `mini-lcm/${type}/${code}/entry/${id}`; await this.fetchPath(path, 'DELETE'); } @@ -74,7 +103,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}-`)); } @@ -85,7 +114,7 @@ export class FwLiteApi { semanticDomain?: string, lexiconCode?: string, ): Promise { - const { code, type } = this.checkLexiconCode(lexiconCode); + const { code, type } = await this.checkLexiconCode(lexiconCode); let path = `mini-lcm/${type}/${code}/entries`; if (search) path += `/${search}`; if (semanticDomain) { @@ -96,47 +125,60 @@ export class FwLiteApi { } async getEntry(id: string, lexiconCode?: string): Promise { - const { code, type } = this.checkLexiconCode(lexiconCode); + const { code, type } = await this.checkLexiconCode(lexiconCode); const path = `mini-lcm/${type}/${code}/entry/${id}`; return (await this.fetchPath(path)) as IEntry; } async getSense(id: string, lexiconCode?: string): Promise { - const { code, type } = this.checkLexiconCode(lexiconCode); + const { code, type } = await this.checkLexiconCode(lexiconCode); const path = `mini-lcm/${type}/${code}/sense/${id}`; return (await this.fetchPath(path)) as ISense; } 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 { - 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 { - const { code, type } = this.checkLexiconCode(lexiconCode); + const { code, type } = await this.checkLexiconCode(lexiconCode); const path = `mini-lcm/${type}/${code}/writingSystems`; return (await this.fetchPath(path)) as IWritingSystems; } async postNewEntry(entry: PartialEntry, lexiconCode?: string): Promise { - const { code, type } = this.checkLexiconCode(lexiconCode); + const { code, type } = await this.checkLexiconCode(lexiconCode); const path = `mini-lcm/${type}/${code}/entry`; return (await this.fetchPath(path, 'POST', entry)) as IEntry; } @@ -164,9 +206,46 @@ export class FwLiteApi { /* eslint-enable no-type-assertion/no-type-assertion */ - private checkLexiconCode(lexiconCode?: string): LexiconRef { - const code = sanitizeUrlComponent(lexiconCode || this.lexiconCode); - return { code, type: 'FwData' }; + async getBrowseUrl(lexiconCode: string, entryId?: string): Promise { + const type = await this.resolveProjectType(lexiconCode); + 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, + 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'); + FwLiteApi.projectTypeByCode.set(code, 'Harmony'); + } + + private async checkLexiconCode(lexiconCode?: string): Promise { + const rawCode = lexiconCode || this.lexiconCode; + const code = sanitizeUrlComponent(rawCode); + const type = await this.resolveProjectType(rawCode ?? ''); + return { code, type }; + } + + /** + * 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. `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; + await this.getProjects(); + return FwLiteApi.projectTypeByCode.get(code) ?? 'FwData'; } private getUrl(path: string): string { 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); + } +} diff --git a/platform.bible-extension/src/utils/project-manager.ts b/platform.bible-extension/src/utils/project-manager.ts index dff0d463e9..034394d76d 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,15 +38,32 @@ 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 (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`, + ); + await this.setLexiconCode(''); + } else { + logger.info(`Lexicon not yet selected for project '${nameOrId}'`); } - logger.info(`Lexicon not yet selected for project '${nameOrId}'`); - await this.openWebView(WebViewType.SelectLexicon, { - floatSize: { height: 500, width: 400 }, - type: 'float', - }); + await this.openSelector(); + } + + async openSelector(): Promise { + const vernacularLanguage = await this.getLanguageTag(); + const options: LexiconWebViewOptions = { vernacularLanguage }; + return await this.openWebView( + WebViewType.SelectLexicon, + { floatSize: { height: 500, width: 400 }, type: 'float' }, + options, + ); } async setLexiconCode(lexiconCode: string): Promise { 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]; } diff --git a/platform.bible-extension/src/web-views/index.tsx b/platform.bible-extension/src/web-views/index.tsx index 39d0f3be7d..07394f6d2d 100644 --- a/platform.bible-extension/src/web-views/index.tsx +++ b/platform.bible-extension/src/web-views/index.tsx @@ -1,5 +1,5 @@ import type { IWebViewProvider, SavedWebViewDefinition, WebViewDefinition } from '@papi/core'; -import type { BrowseWebViewOptions, LexiconWebViewOptions, ProjectWebViewOptions } from 'lexicon'; +import type { BrowseWebViewOptions, LexiconWebViewOptions } from 'lexicon'; import mainCssStyles from '../styles.css?inline'; import tailwindCssStyles from '../tailwind.css?inline'; import { WebViewType } from '../types/enums'; @@ -57,7 +57,7 @@ export const addWordWebViewProvider: IWebViewProvider = { export const selectLexiconWebViewProvider: IWebViewProvider = { async getWebView( savedWebView: SavedWebViewDefinition, - options: ProjectWebViewOptions, + options: LexiconWebViewOptions, ): Promise { if (savedWebView.webViewType !== String(WebViewType.SelectLexicon)) throw new Error( 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..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 @@ -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 { @@ -53,27 +77,74 @@ globalThis.webViewComponent = function LexiconSelect({ projectId }: WebViewProps const selectLexicon = useCallback( async (code: string): Promise => { - await commands.sendCommand('lexicon.selectLexicon', projectId ?? '', code); + const result = await commands.sendCommand('lexicon.selectLexicon', projectId ?? '', code); + if (!result?.success) throw new Error(result?.error || 'Failed to select lexicon'); }, [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(result?.error || 'Failed to create lexicon'); + }, + [], + ); - useEffect(() => { - refreshAuthServers(); - }, [refreshAuthServers]); + const onCreated = useCallback( + async (code: string): Promise => { + try { + await selectLexicon(code); + setDone(true); + } catch (e) { + // 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); + } + }, + [fetchLexicons, selectLexicon], + ); + + if (done) { + return ( +

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

+ ); + } + + if (showCreate) { + return ( + l.code)} + onCancel={() => setShowCreate(false)} + onCreated={onCreated} + /> + ); + } return ( <> - + setShowCreate(true)} + selectLexicon={selectLexicon} + /> ); };