-
-
Notifications
You must be signed in to change notification settings - Fork 6
[PB Extension] Create FW project #2394
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
d489467
833ca0c
323c3f9
af9e953
5bb55e1
77b6250
1f723bd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
| } 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 | ||
|
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> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) { | ||
|
|
@@ -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')); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
|
|
@@ -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 { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the
=== 1about? I'd expect >= 1.