'use client'; import fileDownload from 'js-file-download'; import { createContext, useContext, useEffect, useState } from 'react'; import { toast } from 'react-toastify'; import { useConceptNavigation } from '@/app/Navigation/NavigationContext'; import { urls } from '@/app/urls'; import { useAuth } from '@/backend/auth/useAuth'; import { useSetAccessPolicy } from '@/backend/library/useSetAccessPolicy'; import { useSetEditors } from '@/backend/library/useSetEditors'; import { useSetLocation } from '@/backend/library/useSetLocation'; import { useSetOwner } from '@/backend/library/useSetOwner'; import { useFindPredecessor } from '@/backend/oss/useFindPredecessor'; import { ICstCreateDTO, ICstRenameDTO, ICstUpdateDTO, IInlineSynthesisDTO } from '@/backend/rsform/api'; import { useCstCreate } from '@/backend/rsform/useCstCreate'; import { useCstDelete } from '@/backend/rsform/useCstDelete'; import { useCstMove } from '@/backend/rsform/useCstMove'; import { useCstRename } from '@/backend/rsform/useCstRename'; import { useCstSubstitute } from '@/backend/rsform/useCstSubstitute'; import { useCstUpdate } from '@/backend/rsform/useCstUpdate'; import { useDownloadRSForm } from '@/backend/rsform/useDownloadRSForm'; import { useInlineSynthesis } from '@/backend/rsform/useInlineSynthesis'; import { useIsProcessingRSForm } from '@/backend/rsform/useIsProcessingRSForm'; import { useProduceStructure } from '@/backend/rsform/useProduceStructure'; import { useResetAliases } from '@/backend/rsform/useResetAliases'; import { useRestoreOrder } from '@/backend/rsform/useRestoreOrder'; import { useRSFormSuspense } from '@/backend/rsform/useRSForm'; import { AccessPolicy, ILibraryItemEditor, IVersionData, LibraryItemID, LocationHead, VersionID } from '@/models/library'; import { ICstSubstitutions } from '@/models/oss'; import { ConstituentaID, CstType, IConstituenta, IConstituentaMeta, IRSForm, TermForm } from '@/models/rsform'; import { generateAlias } from '@/models/rsformAPI'; import { UserID, UserRole } from '@/models/user'; import { useDialogsStore } from '@/stores/dialogs'; import { usePreferencesStore } from '@/stores/preferences'; import { useRoleStore } from '@/stores/role'; import { EXTEOR_TRS_FILE } from '@/utils/constants'; import { information, prompts } from '@/utils/labels'; import { promptUnsaved } from '@/utils/utils'; import { RSTabID } from './RSTabs'; export interface IRSEditContext extends ILibraryItemEditor { schema?: IRSForm; selected: ConstituentaID[]; isOwned: boolean; isArchive: boolean; isMutable: boolean; isContentEditable: boolean; isProcessing: boolean; isAttachedToOSS: boolean; canProduceStructure: boolean; canDeleteSelected: boolean; setOwner: (newOwner: UserID) => void; setAccessPolicy: (newPolicy: AccessPolicy) => void; promptEditors: () => void; promptLocation: () => void; setSelected: React.Dispatch>; select: (target: ConstituentaID) => void; deselect: (target: ConstituentaID) => void; toggleSelect: (target: ConstituentaID) => void; deselectAll: () => void; viewOSS: (target: LibraryItemID, newTab?: boolean) => void; viewVersion: (version?: VersionID, newTab?: boolean) => void; viewPredecessor: (target: ConstituentaID) => void; createVersion: () => void; restoreVersion: () => void; promptEditVersions: () => void; moveUp: () => void; moveDown: () => void; createCst: (type: CstType | undefined, skipDialog: boolean, definition?: string) => void; renameCst: () => void; cloneCst: () => void; promptDeleteCst: () => void; editTermForms: () => void; promptTemplate: () => void; promptClone: () => void; promptUpload: () => void; share: () => void; download: () => void; reindex: () => void; reorder: () => void; produceStructure: () => void; inlineSynthesis: () => void; substitute: () => void; showTypeGraph: () => void; showQR: () => void; } const RSEditContext = createContext(null); export const useRSEdit = () => { const context = useContext(RSEditContext); if (context === null) { throw new Error('useRSEdit has to be used within '); } return context; }; interface RSEditStateProps { itemID: LibraryItemID; versionID?: VersionID; selected: ConstituentaID[]; isModified: boolean; setSelected: React.Dispatch>; activeCst?: IConstituenta; onCreateCst?: (newCst: IConstituentaMeta) => void; onDeleteCst?: (newActive?: ConstituentaID) => void; } export const RSEditState = ({ itemID, versionID, selected, setSelected, activeCst, isModified, onCreateCst, onDeleteCst, children }: React.PropsWithChildren) => { const router = useConceptNavigation(); const { user } = useAuth(); const adminMode = usePreferencesStore(state => state.adminMode); const role = useRoleStore(state => state.role); const adjustRole = useRoleStore(state => state.adjustRole); const { schema } = useRSFormSuspense({ itemID: itemID, version: versionID }); const isProcessing = useIsProcessingRSForm(); const { download: downloadFile } = useDownloadRSForm(); const { findPredecessor } = useFindPredecessor(); const { setOwner: setItemOwner } = useSetOwner(); const { setLocation: setItemLocation } = useSetLocation(); const { setAccessPolicy: setItemAccessPolicy } = useSetAccessPolicy(); const { setEditors: setItemEditors } = useSetEditors(); const { cstCreate } = useCstCreate(); const { cstRename } = useCstRename(); const { cstSubstitute } = useCstSubstitute(); const { cstMove } = useCstMove(); const { cstDelete } = useCstDelete(); const { cstUpdate } = useCstUpdate(); const { produceStructure: produceStructureInternal } = useProduceStructure(); const { inlineSynthesis: inlineSynthesisInternal } = useInlineSynthesis(); const { restoreOrder: restoreOrderInternal } = useRestoreOrder(); const { resetAliases: resetAliasesInternal } = useResetAliases(); const isOwned = user?.id === schema?.owner || false; const isArchive = !!versionID; const isMutable = role > UserRole.READER && !schema?.read_only; const isContentEditable = isMutable && !isArchive; const canDeleteSelected = selected.length > 0 && selected.every(id => !schema?.cstByID.get(id)?.is_inherited); const isAttachedToOSS = !!schema && schema.oss.length > 0 && (schema.stats.count_inherited > 0 || schema.items.length === 0); const [renameInitialData, setRenameInitialData] = useState(); const showClone = useDialogsStore(state => state.showCloneLibraryItem); const showCreateVersion = useDialogsStore(state => state.showCreateVersion); const showEditVersions = useDialogsStore(state => state.showEditVersions); const showEditEditors = useDialogsStore(state => state.showEditEditors); const showEditLocation = useDialogsStore(state => state.showChangeLocation); const showCreateCst = useDialogsStore(state => state.showCreateCst); const showDeleteCst = useDialogsStore(state => state.showDeleteCst); const showRenameCst = useDialogsStore(state => state.showRenameCst); const showEditTerm = useDialogsStore(state => state.showEditWordForms); const showSubstituteCst = useDialogsStore(state => state.showSubstituteCst); const showCstTemplate = useDialogsStore(state => state.showCstTemplate); const showInlineSynthesis = useDialogsStore(state => state.showInlineSynthesis); const showTypeGraph = useDialogsStore(state => state.showShowTypeGraph); const showUpload = useDialogsStore(state => state.showUploadRSForm); const showQR = useDialogsStore(state => state.showQR); const typeInfo = schema ? schema.items.map(item => ({ alias: item.alias, result: item.parse.typification, args: item.parse.args })) : []; const canProduceStructure = !!activeCst && !!activeCst.parse.typification && activeCst.cst_type !== CstType.BASE && activeCst.cst_type !== CstType.CONSTANT; useEffect( () => adjustRole({ isOwner: isOwned, isEditor: (user && schema?.editors.includes(user?.id)) ?? false, isStaff: user?.is_staff ?? false, adminMode: adminMode }), [schema, adjustRole, isOwned, user, adminMode] ); function viewVersion(version?: VersionID, newTab?: boolean) { router.push(urls.schema(itemID, version), newTab); } function viewPredecessor(target: ConstituentaID) { findPredecessor({ target: target }, reference => router.push( urls.schema_props({ id: reference.schema, active: reference.id, tab: RSTabID.CST_EDIT }) ) ); } function viewOSS(target: LibraryItemID, newTab?: boolean) { router.push(urls.oss(target), newTab); } function restoreVersion() { if (!versionID || !window.confirm(prompts.restoreArchive)) { return; } model.versionRestore(versionID, () => { toast.success(information.versionRestored); viewVersion(undefined); }); } function calculateCloneLocation() { if (!schema) { return LocationHead.USER; } const location = schema.location; const head = schema.location.substring(0, 2) as LocationHead; if (head === LocationHead.LIBRARY) { return user?.is_staff ? location : LocationHead.USER; } if (schema.owner === user?.id) { return location; } return head === LocationHead.USER ? LocationHead.USER : location; } function handleCreateCst(data: ICstCreateDTO) { if (!schema) { return; } data.alias = data.alias || generateAlias(data.cst_type, schema); cstCreate({ itemID: itemID, data }, newCst => { toast.success(information.newConstituent(newCst.alias)); setSelected([newCst.id]); if (onCreateCst) onCreateCst(newCst); }); } function handleRenameCst(data: ICstRenameDTO) { const oldAlias = renameInitialData?.alias ?? ''; cstRename({ itemID: itemID, data }, () => toast.success(information.renameComplete(oldAlias, data.alias))); } function handleSubstituteCst(data: ICstSubstitutions) { cstSubstitute({ itemID: itemID, data }, () => { setSelected(prev => prev.filter(id => !data.substitutions.find(sub => sub.original === id))); toast.success(information.substituteSingle); }); } function handleDeleteCst(deleted: ConstituentaID[]) { if (!schema) { return; } const data = { items: deleted }; const deletedNames = deleted.map(id => schema.cstByID.get(id)!.alias).join(', '); const isEmpty = deleted.length === schema.items.length; const nextActive = isEmpty ? undefined : getNextActiveOnDelete(activeCst?.id, schema.items, deleted); cstDelete({ itemID: itemID, data }, () => { toast.success(information.constituentsDestroyed(deletedNames)); setSelected(nextActive ? [nextActive] : []); onDeleteCst?.(nextActive); }); } function handleSaveWordforms(forms: TermForm[]) { if (!activeCst) { return; } const data: ICstUpdateDTO = { target: activeCst.id, item_data: { term_forms: forms } }; cstUpdate({ itemID: itemID, data }, () => toast.success(information.changesSaved)); } function handleCreateVersion(data: IVersionData) { if (!schema) { return; } model.versionCreate(data, () => { toast.success(information.newVersion(data.version)); }); } function handleDeleteVersion(versionID: VersionID) { if (!schema) { return; } model.versionDelete(versionID, () => { toast.success(information.versionDestroyed); if (versionID === versionID) { viewVersion(undefined); } }); } function handleUpdateVersion(versionID: VersionID, data: IVersionData) { if (!schema) { return; } model.versionUpdate(versionID, data, () => toast.success(information.changesSaved)); } const handleSetLocation = (newLocation: string) => setItemLocation({ itemID: itemID, location: newLocation }, () => toast.success(information.moveComplete)); function handleInlineSynthesis(data: IInlineSynthesisDTO) { if (!schema) { return; } const oldCount = schema.items.length; inlineSynthesisInternal({ itemID: itemID, data }, newSchema => { setSelected([]); toast.success(information.addedConstituents(newSchema.items.length - oldCount)); }); } function createVersion() { if (!schema || (isModified && !promptUnsaved())) { return; } showCreateVersion({ versions: schema.versions, onCreate: handleCreateVersion, selected: selected, totalCount: schema.items.length }); } function promptEditVersions() { if (!schema) { return; } showEditVersions({ versions: schema.versions, onDelete: handleDeleteVersion, onUpdate: handleUpdateVersion }); } function moveUp() { if (!schema?.items || selected.length === 0) { return; } const currentIndex = schema.items.reduce((prev, cst, index) => { if (!selected.includes(cst.id)) { return prev; } else if (prev === -1) { return index; } return Math.min(prev, index); }, -1); const target = Math.max(0, currentIndex - 1); cstMove({ itemID: itemID, data: { items: selected, move_to: target } }); } function moveDown() { if (!schema?.items || selected.length === 0) { return; } let count = 0; const currentIndex = schema.items.reduce((prev, cst, index) => { if (!selected.includes(cst.id)) { return prev; } else { count += 1; if (prev === -1) { return index; } return Math.max(prev, index); } }, -1); const target = Math.min(schema.items.length - 1, currentIndex - count + 2); cstMove({ itemID: itemID, data: { items: selected, move_to: target } }); } function createCst(type: CstType | undefined, skipDialog: boolean, definition?: string) { if (!schema) { return; } const targetType = type ?? activeCst?.cst_type ?? CstType.BASE; const data: ICstCreateDTO = { insert_after: activeCst?.id ?? null, cst_type: targetType, alias: generateAlias(targetType, schema), term_raw: '', definition_formal: definition ?? '', definition_raw: '', convention: '', term_forms: [] }; if (skipDialog) { handleCreateCst(data); } else { showCreateCst({ schema: schema, onCreate: handleCreateCst, initial: data }); } } function cloneCst() { if (!activeCst || !schema) { return; } const data: ICstCreateDTO = { insert_after: activeCst.id, cst_type: activeCst.cst_type, alias: generateAlias(activeCst.cst_type, schema), term_raw: activeCst.term_raw, definition_formal: activeCst.definition_formal, definition_raw: activeCst.definition_raw, convention: activeCst.convention, term_forms: activeCst.term_forms }; handleCreateCst(data); } function renameCst() { if (!activeCst || !schema) { return; } const data: ICstRenameDTO = { target: activeCst.id, alias: activeCst.alias, cst_type: activeCst.cst_type }; setRenameInitialData(data); showRenameCst({ schema: schema, initial: data, allowChangeType: !activeCst.is_inherited, onRename: handleRenameCst }); } function substitute() { if (!schema || (isModified && !promptUnsaved())) { return; } showSubstituteCst({ schema: schema, onSubstitute: handleSubstituteCst }); } function inlineSynthesis() { if (!schema || (isModified && !promptUnsaved())) { return; } showInlineSynthesis({ receiver: schema, onInlineSynthesis: handleInlineSynthesis }); } function promptDeleteCst() { if (!schema) { return; } showDeleteCst({ schema: schema, selected: selected, onDelete: handleDeleteCst }); } function editTermForms() { if (!activeCst) { return; } if (isModified && !promptUnsaved()) { return; } showEditTerm({ target: activeCst, onSave: handleSaveWordforms }); } function reindex() { if (!itemID) { return; } resetAliasesInternal(itemID, () => toast.success(information.reindexComplete)); } function reorder() { if (!itemID) { return; } restoreOrderInternal(itemID, () => toast.success(information.reorderComplete)); } function produceStructure() { if (!activeCst) { return; } if (isModified && !promptUnsaved()) { return; } produceStructureInternal({ itemID: itemID, data: { target: activeCst.id } }, cstList => { toast.success(information.addedConstituents(cstList.length)); if (cstList.length !== 0) { setSelected(cstList); } }); } function handleSetEditors(newEditors: UserID[]) { setItemEditors({ itemID: itemID, editors: newEditors }, () => toast.success(information.changesSaved)); } function promptTemplate() { if ((isModified && !promptUnsaved()) || !schema) { return; } showCstTemplate({ schema: schema, onCreate: handleCreateCst, insertAfter: activeCst?.id }); } function promptClone() { if (!schema || (isModified && !promptUnsaved())) { return; } showClone({ base: schema, initialLocation: calculateCloneLocation(), selected: selected, totalCount: schema.items.length }); } function promptEditors() { if (!schema) { return; } showEditEditors({ editors: schema.editors, setEditors: handleSetEditors }); } function promptLocation() { if (!schema) { return; } showEditLocation({ initial: schema.location, onChangeLocation: handleSetLocation }); } function download() { if ((isModified && !promptUnsaved()) || !schema) { return; } const fileName = (schema.alias ?? 'Schema') + EXTEOR_TRS_FILE; downloadFile({ itemID: itemID, version: versionID }, (data: Blob) => { try { fileDownload(data, fileName); } catch (error) { console.error(error); } }); } function share() { const currentRef = window.location.href; const url = currentRef.includes('?') ? currentRef + '&share' : currentRef + '?share'; navigator.clipboard .writeText(url) .then(() => toast.success(information.linkReady)) .catch(console.error); } function setOwner(newOwner: UserID) { setItemOwner({ itemID: itemID, owner: newOwner }, () => toast.success(information.changesSaved)); } function setAccessPolicy(newPolicy: AccessPolicy) { setItemAccessPolicy({ itemID: itemID, policy: newPolicy }, () => toast.success(information.changesSaved)); } function generateQR(): string { const currentRef = window.location.href; return currentRef.includes('?') ? currentRef + '&qr' : currentRef + '?qr'; } return ( setSelected(prev => [...prev, target]), deselect: (target: ConstituentaID) => setSelected(prev => prev.filter(id => id !== target)), toggleSelect: (target: ConstituentaID) => setSelected(prev => (prev.includes(target) ? prev.filter(id => id !== target) : [...prev, target])), deselectAll: () => setSelected([]), viewOSS, viewVersion, viewPredecessor, createVersion, restoreVersion, promptEditVersions, moveUp, moveDown, createCst, cloneCst, renameCst, promptDeleteCst, editTermForms, promptTemplate, promptClone, promptUpload: () => showUpload({ itemID: model.itemID! }), download, share, reindex, reorder, inlineSynthesis, produceStructure, substitute, showTypeGraph: () => showTypeGraph({ items: typeInfo }), showQR: () => showQR({ target: generateQR() }) }} > {children} ); }; // ====== Internals ========= function getNextActiveOnDelete( activeID: ConstituentaID | undefined, items: IConstituenta[], deleted: ConstituentaID[] ): ConstituentaID | undefined { if (items.length === deleted.length) { return undefined; } let activeIndex = items.findIndex(cst => cst.id === activeID); if (activeIndex === -1) { return undefined; } while (activeIndex < items.length && deleted.find(id => id === items[activeIndex].id)) { ++activeIndex; } if (activeIndex >= items.length) { activeIndex = items.length - 1; while (activeIndex >= 0 && deleted.find(id => id === items[activeIndex].id)) { --activeIndex; } } return items[activeIndex].id; }