diff --git a/platform.bible-extension/contributions/localizedStrings.json b/platform.bible-extension/contributions/localizedStrings.json index 65febacf79..c41b10f3e3 100644 --- a/platform.bible-extension/contributions/localizedStrings.json +++ b/platform.bible-extension/contributions/localizedStrings.json @@ -6,6 +6,14 @@ "%lexicon_addWord_buttonSubmit%": "Submit new entry", "%lexicon_addWord_title%": "Add entry to lexicon", "%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 new file mode 100644 index 0000000000..6036849fa3 --- /dev/null +++ b/platform.bible-extension/src/components/create-lexicon.tsx @@ -0,0 +1,144 @@ +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-]*$/; + +function isValidLangTag(tag: string): boolean { + try { + return Intl.getCanonicalLocales(tag).length === 1; + } catch { + return false; + } +} + +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); + const [error, setError] = useState(''); + + useEffect(() => { + if (!codeEdited) setCode(deriveCode(name)); + }, [codeEdited, name]); + + const isValid = !!( + name.trim() && + CODE_PATTERN.test(code) && + isValidLangTag(vernacularWs.trim()) && + (!analysisWs.trim() || isValidLangTag(analysisWs.trim())) + ); + + 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); + } + }; + + 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)} + placeholder="en" + value={analysisWs} + /> +
+ + {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..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 4fb6ed70ac..acbe1a9c51 100644 --- a/platform.bible-extension/src/main.ts +++ b/platform.bible-extension/src/main.ts @@ -5,7 +5,7 @@ import type { BrowseWebViewOptions } from 'lexicon'; import { Stream } from 'stream'; import { EntryService } from './services/entry-service'; import { WebViewType } from './types/enums'; -import { FwLiteApi, getBrowseUrl } from './utils/fw-lite-api'; +import { FwLiteApi } from './utils/fw-lite-api'; import { ProjectManagers } from './utils/project-managers'; import * as webViewProviders from './web-views'; @@ -111,7 +111,7 @@ export async function activate(context: ExecutionActivationContext): Promise { + 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) => { @@ -224,6 +238,7 @@ export async function activate(context: ExecutionActivationContext): Promise Promise; + 'lexicon.createLexicon': ( + name: string, + code: string, + vernacularWs: string, + analysisWs?: string, + ) => Promise; 'lexicon.browseLexicon': (webViewId: string) => Promise; 'lexicon.displayEntry': (projectId: string, entryId: string) => Promise; 'lexicon.findEntry': (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 89d3043849..b21aa8375b 100644 --- a/platform.bible-extension/src/types/localized-string-keys.ts +++ b/platform.bible-extension/src/types/localized-string-keys.ts @@ -5,6 +5,14 @@ export const LOCALIZED_STRING_KEYS: LocalizeKey[] = [ '%lexicon_addWord_buttonSubmit%', '%lexicon_addWord_title%', '%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 d6ca905b17..7be7ad3de8 100644 --- a/platform.bible-extension/src/utils/fw-lite-api.ts +++ b/platform.bible-extension/src/utils/fw-lite-api.ts @@ -29,18 +29,18 @@ 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.statusText}`); + const errorBody = await results.text(); + throw new Error(errorBody || `Failed to fetch: ${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) { @@ -53,7 +53,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'); } @@ -73,7 +73,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) { @@ -84,23 +84,25 @@ 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 { @@ -118,22 +120,64 @@ export class FwLiteApi { } 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; } /* 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; 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. + */ + 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), + ); + } + return FwLiteApi.projectTypeByCode.get(code) ?? 'FwData'; } private getUrl(path: string): string { diff --git a/platform.bible-extension/src/utils/project-manager.ts b/platform.bible-extension/src/utils/project-manager.ts index dff0d463e9..5091d97982 100644 --- a/platform.bible-extension/src/utils/project-manager.ts +++ b/platform.bible-extension/src/utils/project-manager.ts @@ -41,10 +41,13 @@ 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(); + const options: LexiconWebViewOptions = { vernacularLanguage }; + 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/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 3f0d1db5a3..ad241b4671 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,26 +1,99 @@ -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 CreateLexicon from '../components/create-lexicon'; import LexiconComboBox from '../components/lexicon-combo-box'; +import { LOCALIZED_STRING_KEYS } from '../types/localized-string-keys'; -globalThis.webViewComponent = function LexiconSelect({ projectId }: WebViewProps) { +globalThis.webViewComponent = function LexiconSelect({ + projectId, + vernacularLanguage, +}: LexiconWebViewProps) { + const [localizedStrings] = useLocalizedStrings(LOCALIZED_STRING_KEYS); const [lexicons, setLexicons] = useState(); + const [showCreate, setShowCreate] = useState(false); + const [done, setDone] = 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]); 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'); + }, + [], + ); + + const onCreated = useCallback( + async (code: string): Promise => { + 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); + } + }, + [fetchLexicons, selectLexicon], + ); - return ; + if (done) { + return ( +

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

+ ); + } + + if (showCreate) { + return ( + setShowCreate(false)} + onCreated={onCreated} + /> + ); + } + + return ( + setShowCreate(true)} + selectLexicon={selectLexicon} + /> + ); };