Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9421522
Add createProject to FwLiteApi, fix empty-body fetch handling
imnasnainaec Jun 30, 2026
4937c0f
Add Create new lexicon option to SelectLexicon WebView
imnasnainaec Jul 20, 2026
fc49101
Address review: fix CRDT type routing, error bodies, tag validation, …
imnasnainaec Jul 20, 2026
ebe0619
Address review: deriveCode parity with backend, resilient onCreated
imnasnainaec Jun 30, 2026
fa37e75
Fix getBrowseUrl to use correct path segment for CRDT projects
imnasnainaec Jul 20, 2026
ed5889a
Add en placeholder to analysis WS input
imnasnainaec Jul 2, 2026
909316b
Surface lexicon creation errors in the form
imnasnainaec Jul 3, 2026
ad153f5
Surface lexicon-creation errors and fix vernacularLanguage WebView ty…
imnasnainaec Jul 9, 2026
53b4124
Repopulate project-type cache on miss so restarts don't misroute lexi…
imnasnainaec Jul 9, 2026
8edfde3
Fail lexicon selection loudly when the command reports no success
imnasnainaec Jul 9, 2026
cf65ddd
Harden lexicon-code validation and pre-check duplicate codes
imnasnainaec Jul 10, 2026
a77da24
Fix language filtering by reading wsId instead of array indices
imnasnainaec Jul 10, 2026
1b571e7
Show validation message for invalid lexicon codes
imnasnainaec Jul 13, 2026
e337f6a
Align lexicon code validation with Lexbox's create-project rules
imnasnainaec Jul 13, 2026
1973826
Add lang-tag validation and recover from a deleted lexicon
imnasnainaec Jul 21, 2026
85633c5
Tweak comments
imnasnainaec Jul 21, 2026
8db3304
Tighten lexicon create validation comments
imnasnainaec Jul 21, 2026
99d4dfc
Don't treat a transient backend failure as a confirmed lexicon deletion
imnasnainaec Jul 24, 2026
f71d97d
Unwrap JSON error bodies and stop one bad project from killing langua…
imnasnainaec Jul 24, 2026
3e81efa
Surface lexicon-selection save failures in the combo box
imnasnainaec Jul 24, 2026
5afa856
Align create-lexicon labels with Paratext terminology
imnasnainaec Aug 7, 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 writing system code (optional)",
"%lexicon_createLexicon_button%": "Create new lexicon",
"%lexicon_createLexicon_code%": "Lexicon code",
"%lexicon_createLexicon_codeExists%": "A lexicon with this code already exists.",
"%lexicon_createLexicon_codeInvalid%": "Lexicon code must start with a lowercase letter or digit, and contain only lowercase letters, digits, and hyphens.",
"%lexicon_createLexicon_codeTooShort%": "Lexicon 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 writing system code",
"%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