Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions platform.bible-extension/contributions/localizedStrings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
144 changes: 144 additions & 0 deletions platform.bible-extension/src/components/create-lexicon.tsx
Original file line number Diff line number Diff line change
@@ -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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the === 1 about? I'd expect >= 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<void>;
defaultVernacularWs?: string;
onCancel: () => void;
onCreated: (code: string) => Promise<void>;
}

/** 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 (
<div className="tw:flex tw:flex-col tw:gap-1 tw:p-4">
<h3 className="tw:font-semibold tw:mb-2">
{localizedStrings['%lexicon_createLexicon_title%']}
</h3>

<div>
<Label htmlFor="createLexiconName">
{localizedStrings['%lexicon_createLexicon_name%']}
</Label>
<Input id="createLexiconName" onChange={(e) => setName(e.target.value)} value={name} />
</div>

<div>
<Label htmlFor="createLexiconCode">
{localizedStrings['%lexicon_createLexicon_code%']}
</Label>
<Input
id="createLexiconCode"
onChange={(e) => {
setCode(e.target.value);
setCodeEdited(true);
}}
value={code}
/>
</div>

<div>
<Label htmlFor="createLexiconVernWs">
{localizedStrings['%lexicon_createLexicon_vernacularWs%']}
</Label>
<Input
id="createLexiconVernWs"
onChange={(e) => setVernacularWs(e.target.value)}
value={vernacularWs}
/>
</div>

<div>
<Label htmlFor="createLexiconAnalysisWs">
{localizedStrings['%lexicon_createLexicon_analysisWs%']}
</Label>
<Input
Comment thread
imnasnainaec marked this conversation as resolved.
id="createLexiconAnalysisWs"
onChange={(e) => setAnalysisWs(e.target.value)}
placeholder="en"
value={analysisWs}
/>
</div>

{error && <p className="tw:text-sm tw:text-destructive tw:mt-1">{error}</p>}

<div className="tw:flex tw:gap-1 tw:mt-2">
<Button disabled={!isValid || creating} onClick={() => handleSubmit()}>
{localizedStrings['%lexicon_createLexicon_submit%']}
</Button>
<Button onClick={onCancel} variant="secondary">
{localizedStrings['%lexicon_button_cancel%']}
</Button>
</div>
</div>
);
}
8 changes: 8 additions & 0 deletions platform.bible-extension/src/components/lexicon-combo-box.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
}

/** 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);
Expand Down Expand Up @@ -85,6 +87,12 @@ export default function LexiconComboBox({
</Button>
</div>
)}

{!!onCreateNew && (
<Button onClick={onCreateNew} type="button" variant="secondary">
{localizedStrings['%lexicon_createLexicon_button%']}
</Button>
)}
</div>
);
}
20 changes: 17 additions & 3 deletions platform.bible-extension/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -111,7 +111,7 @@ export async function activate(context: ExecutionActivationContext): Promise<voi
const lexiconCode = await projectManager.getLexiconCodeOrOpenSelector();
if (!lexiconCode) return { success };

const url = getBrowseUrl(baseUrl, lexiconCode);
const url = fwLiteApi.getBrowseUrl(lexiconCode);
const options: BrowseWebViewOptions = { url };
success = await projectManager.openWebView(WebViewType.Main, undefined, options);
return { success };
Expand All @@ -130,7 +130,7 @@ export async function activate(context: ExecutionActivationContext): Promise<voi
if (!lexiconCode) return { success };

logger.info(`Displaying entry '${entryId}' in lexicon '${lexiconCode}'`);
const url = getBrowseUrl(baseUrl, lexiconCode, entryId);
const url = fwLiteApi.getBrowseUrl(lexiconCode, entryId);
const options: BrowseWebViewOptions = { url };
success = await projectManager.openWebView(WebViewType.Main, undefined, options);
return { success };
Expand Down Expand Up @@ -197,6 +197,19 @@ export async function activate(context: ExecutionActivationContext): Promise<voi
},
);

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) {
logger.error('Error creating lexicon:', JSON.stringify(e));
return { success: false };
}
},
);

const lexiconsCommandPromise = papi.commands.registerCommand(
'lexicon.lexicons',
async (projectId?: string) => {
Expand Down Expand Up @@ -224,6 +237,7 @@ export async function activate(context: ExecutionActivationContext): Promise<voi
// Commands
await addEntryCommandPromise,
await browseLexiconCommandPromise,
await createLexiconCommandPromise,
await displayEntryCommandPromise,
await findEntryCommandPromise,
await findRelatedEntriesCommandPromise,
Expand Down
6 changes: 6 additions & 0 deletions platform.bible-extension/src/types/lexicon.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ declare module 'lexicon' {
declare module 'papi-shared-types' {
export interface CommandHandlers {
'lexicon.addEntry': (webViewId: string, entry: string) => Promise<SuccessHolder>;
'lexicon.createLexicon': (
name: string,
code: string,
vernacularWs: string,
analysisWs?: string,
) => Promise<SuccessHolder>;
'lexicon.browseLexicon': (webViewId: string) => Promise<SuccessHolder>;
'lexicon.displayEntry': (projectId: string, entryId: string) => Promise<SuccessHolder>;
'lexicon.findEntry': (webViewId: string, entry: string) => Promise<SuccessHolder>;
Expand Down
8 changes: 8 additions & 0 deletions platform.bible-extension/src/types/localized-string-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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%',
Expand Down
48 changes: 36 additions & 12 deletions platform.bible-extension/src/utils/fw-lite-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,18 @@ async function fetchUrl(input: string, init?: RequestInit): Promise<unknown> {
}
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<string, 'FwData' | 'Harmony'>();

private readonly baseUrl: string;
private lexiconCode?: string;
constructor(baseUrl: string, lexiconCode?: string) {
Expand Down Expand Up @@ -96,11 +96,13 @@ export class FwLiteApi {
}

async getProjects(): Promise<IProjectModel[]> {
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'));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm not mistaken, these projects are sometimes flagged as both crdt and fwdata.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and if the project has both flags, don't we want to default to Harmony interaction?

return projects;
}

async getProjectsMatchingLanguage(langTag?: string): Promise<IProjectModel[]> {
const projects = (await this.fetchPath('localProjects')) as IProjectModel[];
const projects = await this.getProjects();
if (!langTag?.trim()) return projects;

try {
Expand Down Expand Up @@ -131,9 +133,31 @@ 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,
vernacularWs: string,
analysisWs?: string,
): Promise<void> {
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 {
Expand Down
10 changes: 6 additions & 4 deletions platform.bible-extension/src/utils/project-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand Down
Loading
Loading