Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4d6335f
Add createProject to FwLiteApi, fix empty-body fetch handling
imnasnainaec Jun 30, 2026
2b91c54
Add Create new lexicon option to SelectLexicon WebView
imnasnainaec Jul 20, 2026
54748c2
Address review: fix CRDT type routing, error bodies, tag validation, …
imnasnainaec Jul 20, 2026
bbaacfd
Address review: deriveCode parity with backend, resilient onCreated
imnasnainaec Jun 30, 2026
180824a
Fix getBrowseUrl to use correct path segment for CRDT projects
imnasnainaec Jul 20, 2026
ad032f1
Add en placeholder to analysis WS input
imnasnainaec Jul 2, 2026
d9996e8
Surface lexicon creation errors in the form
imnasnainaec Jul 3, 2026
570b4c1
Surface lexicon-creation errors and fix vernacularLanguage WebView ty…
imnasnainaec Jul 9, 2026
b377039
Repopulate project-type cache on miss so restarts don't misroute lexi…
imnasnainaec Jul 9, 2026
5971d28
Fail lexicon selection loudly when the command reports no success
imnasnainaec Jul 9, 2026
085201b
Harden lexicon-code validation and pre-check duplicate codes
imnasnainaec Jul 10, 2026
057b7fb
Fix language filtering by reading wsId instead of array indices
imnasnainaec Jul 10, 2026
4436f18
Show validation message for invalid lexicon codes
imnasnainaec Jul 13, 2026
5f9f128
Align lexicon code validation with Lexbox's create-project rules
imnasnainaec Jul 13, 2026
75121ff
Add lang-tag validation and recover from a deleted lexicon
imnasnainaec Jul 21, 2026
09df8f0
Tweak comments
imnasnainaec Jul 21, 2026
f212c51
Tighten lexicon create validation comments
imnasnainaec Jul 21, 2026
6a407ba
Don't treat a transient backend failure as a confirmed lexicon deletion
imnasnainaec Jul 24, 2026
258add7
Unwrap JSON error bodies and stop one bad project from killing langua…
imnasnainaec Jul 24, 2026
91c442b
Surface lexicon-selection save failures in the combo box
imnasnainaec Jul 24, 2026
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
15 changes: 14 additions & 1 deletion platform.bible-extension/contributions/localizedStrings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions platform.bible-extension/contributions/menus.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@
"group": "lexicon.editor",
"order": 4,
"command": "lexicon.findRelatedEntries"
},
{
"label": "%lexicon_menu_selectLexicon%",
"group": "lexicon.editor",
"order": 5,
"command": "lexicon.changeLexicon"
}
]
}
Expand Down
195 changes: 195 additions & 0 deletions platform.bible-extension/src/components/create-lexicon.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
defaultVernacularWs?: string;
existingCodes?: 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,
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))
);
Comment thread
imnasnainaec marked this conversation as resolved.

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 (
<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.toLowerCase());
setCodeEdited(true);
}}
value={code}
/>
{codeExists && (
<p className="tw:text-sm tw:text-destructive tw:mt-1">
{localizedStrings['%lexicon_createLexicon_codeExists%']}
</p>
)}
{!codeExists && !!code && code.length < MIN_CODE_LENGTH && (
<p className="tw:text-sm tw:text-destructive tw:mt-1">
{localizedStrings['%lexicon_createLexicon_codeTooShort%']}
</p>
)}
{!codeExists && code.length >= MIN_CODE_LENGTH && !CODE_PATTERN.test(code) && (
<p className="tw:text-sm tw:text-destructive tw:mt-1">
{localizedStrings['%lexicon_createLexicon_codeInvalid%']}
</p>
)}
</div>

<div>
<Label htmlFor="createLexiconVernWs">
{localizedStrings['%lexicon_createLexicon_vernacularWs%']}
</Label>
<Input
id="createLexiconVernWs"
onChange={(e) => setVernacularWs(e.target.value)}
value={vernacularWs}
/>
Comment thread
imnasnainaec marked this conversation as resolved.
{!!vernacularWs.trim() && !isValidLangTag(vernacularWs) && (
<p className="tw:text-sm tw:text-destructive tw:mt-1">
{localizedStrings['%lexicon_createLexicon_langTagInvalid%']}
</p>
)}
</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}
/>
{!!analysisWs.trim() && !isValidLangTag(analysisWs) && (
<p className="tw:text-sm tw:text-destructive tw:mt-1">
{localizedStrings['%lexicon_createLexicon_langTagInvalid%']}
</p>
)}
</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>
);
}
19 changes: 16 additions & 3 deletions platform.bible-extension/src/components/lexicon-combo-box.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,35 @@ 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);

const [saveError, setSaveError] = useState('');
const [selectedLexiconCode, setSelectedLexiconCode] = useState('');
const [settingSaved, setSettingSaved] = useState(false);
const [settingSaving, setSettingSaving] = useState(false);

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],
Expand Down Expand Up @@ -74,6 +79,8 @@ export default function LexiconComboBox({
textPlaceholder={localizedStrings['%lexicon_selectLexicon_select%']}
/>

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

{!!selectedLexiconCode && (
<div className="tw:flex tw:gap-2 tw:items-center">
<Button onClick={() => saveSetting(selectedLexiconCode)} type="button">
Expand All @@ -85,6 +92,12 @@ export default function LexiconComboBox({
</Button>
</div>
)}

{!!onCreateNew && (
<Button onClick={onCreateNew} type="button" variant="secondary">
{localizedStrings['%lexicon_createLexicon_button%']}
</Button>
)}
</div>
);
}
Loading