+
{showHeader ? (
{t('settings.title')}
diff --git a/src/components/sync/SyncConflictPanel.jsx b/src/components/sync/SyncConflictPanel.jsx
new file mode 100644
index 0000000..eb07342
--- /dev/null
+++ b/src/components/sync/SyncConflictPanel.jsx
@@ -0,0 +1,53 @@
+import { useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { liveQuery } from 'dexie';
+import { getRepositoryContext } from '@/lib/db';
+import { subscribeProfileChanges } from '@/lib/profile/activeProfile';
+import { useSyncStore } from '@/stores/syncStore';
+import { Button } from '@/components/ui/button';
+import { NativeSelect } from '@/components/ui/native-select';
+
+/** Global placement includes projects deleted locally while a competing edit exists. */
+export function SyncConflictPanel() {
+ const { t } = useTranslation();
+ const [conflicts, setConflicts] = useState([]), [choices, setChoices] = useState({}), [error, setError] = useState(''), [busy, setBusy] = useState(false);
+ useEffect(() => {
+ let subscription;
+ const subscribe = () => {
+ subscription?.unsubscribe(); setConflicts([]);
+ subscription = liveQuery(async () => {
+ const context = await getRepositoryContext();
+ const rows = await context.db.syncConflicts.where('status').equals('unresolved').toArray();
+ const items = await Promise.all(rows.map(async row => ({ ...row, revisions: (await context.db.projectRevisions.bulkGet(row.revisionIds)).filter(Boolean) })));
+ context.assertCurrent(); return items;
+ }).subscribe({ next: setConflicts, error: failure => { if (failure.code !== 'STALE_PROFILE') setError(failure.message); } });
+ };
+ subscribe(); const unsubscribe = subscribeProfileChanges(subscribe);
+ return () => { subscription?.unsubscribe(); unsubscribe(); };
+ }, []);
+ const resolve = async (conflict, choice) => {
+ setBusy(true); setError('');
+ try { await useSyncStore.getState().resolveConflict(conflict.id, choice, choice === 'remote' ? choices[conflict.id] || conflict.revisions.find(row => row.id !== conflict.localRevisionId)?.id : undefined); }
+ catch (failure) { setError(failure.message || t('syncConflicts.resolveError')); }
+ finally { setBusy(false); }
+ };
+ if (!conflicts.length && !error) return null;
+ return
;
+}
diff --git a/src/components/sync/SyncLoginDialog.jsx b/src/components/sync/SyncLoginDialog.jsx
index 6e1ba16..a9cc621 100644
--- a/src/components/sync/SyncLoginDialog.jsx
+++ b/src/components/sync/SyncLoginDialog.jsx
@@ -19,6 +19,7 @@ import { cn } from '@/lib/utils';
import { isMobileDevice } from '@/lib/deviceDetection';
import { GoogleDriveIcon } from '@/components/sync/GoogleDriveIcon';
import { SteamIcon } from '@/components/sync/SteamIcon';
+import { SyncQueueStatus } from '@/components/sync/SyncQueueStatus';
import { isGoogleSyncEnabled, isSteamChannel } from '@/lib/runtimeConfig';
import { isElectronRuntime } from '@/lib/electronRuntime';
@@ -43,6 +44,8 @@ export function SyncLoginDialog({
setProviderPassphrase,
setProviderAccountLabel,
setProviderRememberPassphrase,
+ canRememberCredentials,
+ cancelAuthentication,
} = useSyncStore();
const steamStatus = useSteamStore((state) => state.status);
const openSteamOverlay = useSteamStore((state) => state.openOverlay);
@@ -91,8 +94,10 @@ export function SyncLoginDialog({
useEffect(() => {
if (open) return;
+ cancelAuthentication();
steamAutoConnectAttemptedRef.current = false;
- }, [open]);
+ }, [open, cancelAuthentication]);
+ useEffect(() => () => cancelAuthentication(), [cancelAuthentication]);
useEffect(() => {
if (provider === 'steam' && showSteamProvider) {
@@ -232,6 +237,7 @@ export function SyncLoginDialog({
)}
>
+
{t('sync.rememberHint')}
setProviderRememberPassphrase('googleDrive', value)
}
- disabled={isGoogleConnected || isGoogleBusy}
+ disabled={!canRememberCredentials || isGoogleConnected || isGoogleBusy}
/>
diff --git a/src/components/sync/SyncQueueStatus.jsx b/src/components/sync/SyncQueueStatus.jsx
new file mode 100644
index 0000000..7599191
--- /dev/null
+++ b/src/components/sync/SyncQueueStatus.jsx
@@ -0,0 +1,28 @@
+import { useEffect, useState } from 'react';
+import { liveQuery } from 'dexie';
+import { useTranslation } from 'react-i18next';
+import { getRevisionQueueStatus } from '@/lib/sync/core/revisionProtocol';
+import { useSyncStore } from '@/stores/syncStore';
+import { subscribeProfileChanges } from '@/lib/profile/activeProfile';
+
+export function SyncQueueStatus() {
+ const { t } = useTranslation();
+ const provider = useSyncStore(state => state.provider);
+ const [queue, setQueue] = useState(null);
+ useEffect(() => {
+ let subscription;
+ const subscribe = () => {
+ subscription?.unsubscribe(); setQueue(null);
+ if (provider) subscription = liveQuery(() => getRevisionQueueStatus(provider)).subscribe({ next: setQueue, error: () => setQueue(null) });
+ };
+ subscribe(); const unsubscribe = subscribeProfileChanges(subscribe);
+ return () => { subscription?.unsubscribe(); unsubscribe(); };
+ }, [provider]);
+ if (!queue) return null;
+ return
+ {t('syncQueue.counts', { defaultValue: 'Queued: {{queued}} · Uploaded: {{uploaded}} · Verified: {{verified}}', ...queue })}
+ {!!queue.pending && {t('syncQueue.age', { defaultValue: 'Oldest pending change: {{minutes}} minutes', minutes: Math.floor(queue.oldestPendingAgeMs / 60000) })}
}
+ {!!queue.conflicts && {t('syncQueue.conflicts', { defaultValue: 'Unresolved conflicts: {{count}}', count: queue.conflicts })}
}
+ {!!queue.errorCodes.length && {t('syncQueue.errors', { defaultValue: 'Delivery errors: {{codes}}', codes: queue.errorCodes.join(', ') })}
}
+ ;
+}
diff --git a/src/components/ui/AppErrorBoundary.jsx b/src/components/ui/AppErrorBoundary.jsx
index 69559e3..b73d9bf 100644
--- a/src/components/ui/AppErrorBoundary.jsx
+++ b/src/components/ui/AppErrorBoundary.jsx
@@ -1,12 +1,13 @@
+import i18n from '@/i18n';
import * as Sentry from '@sentry/react';
function FallbackScreen() {
return (
-
Unexpected Error
+
{i18n.t('errors.unexpectedTitle')}
- An unexpected error occurred. Please restart the app and try again.
+ {i18n.t('errors.unexpectedDescription')}
diff --git a/src/components/ui/dialog.jsx b/src/components/ui/dialog.jsx
index 2729cb0..0c4e511 100644
--- a/src/components/ui/dialog.jsx
+++ b/src/components/ui/dialog.jsx
@@ -1,3 +1,4 @@
+import i18n from '@/i18n';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
@@ -37,7 +38,7 @@ const DialogContent = React.forwardRef(
{showClose && (
- Close
+ {i18n.t('common.close')}
)}
diff --git a/src/components/ui/drawer.jsx b/src/components/ui/drawer.jsx
index a3d86e9..14b69ea 100644
--- a/src/components/ui/drawer.jsx
+++ b/src/components/ui/drawer.jsx
@@ -34,11 +34,18 @@ const RIGHT_DRAWER_SIZE_CLASS = {
full: 'max-w-full',
};
-const DrawerContent = React.forwardRef(({ className, children, side = 'bottom', size = 'md', ...props }, ref) => (
+const DrawerContent = React.forwardRef(({ className, children, side = 'bottom', size = 'md', onOpenAutoFocus, onCloseAutoFocus, ...props }, ref) => {
+ const returnFocus = React.useRef(null);
+ return (
{ returnFocus.current = document.activeElement; onOpenAutoFocus?.(event); }}
+ onCloseAutoFocus={(event) => {
+ onCloseAutoFocus?.(event);
+ if (!event.defaultPrevented && returnFocus.current?.isConnected) { event.preventDefault(); returnFocus.current.focus(); }
+ }}
className={cn(
side === 'right'
? 'fixed right-0 top-0 z-50 flex h-full w-full flex-col border-l bg-background'
@@ -52,7 +59,8 @@ const DrawerContent = React.forwardRef(({ className, children, side = 'bottom',
{children}
-));
+);
+});
DrawerContent.displayName = DrawerPrimitive.Content.displayName;
const DrawerHeader = ({ className, ...props }) => (
diff --git a/src/hooks/useEditorDraft.js b/src/hooks/useEditorDraft.js
new file mode 100644
index 0000000..861afdd
--- /dev/null
+++ b/src/hooks/useEditorDraft.js
@@ -0,0 +1,49 @@
+import { useCallback, useRef, useState } from 'react';
+import { applyNodeChanges, applyEdgeChanges } from '@xyflow/react';
+import { cloneGraphDraft, createEditorDraft, editorDraftReducer } from '@/lib/editorDraft';
+
+export function useEditorDraft(initialNodes, initialEdges) {
+ const [state, render] = useState(() => createEditorDraft(initialNodes, initialEdges));
+ const current = useRef(state);
+ // Resolve event updates once against the current draft, never inside a React
+ // functional updater (which React may defer or replay in StrictMode).
+ const dispatch = useCallback((action) => {
+ current.current = editorDraftReducer(current.current, action);
+ render(current.current);
+ return current.current;
+ }, []);
+ const setNodes = useCallback((value) => {
+ const next = typeof value === 'function' ? value(current.current.nodes) : value;
+ dispatch({ type: 'nodes', value: next });
+ }, [dispatch]);
+ const setEdges = useCallback((value) => {
+ const next = typeof value === 'function' ? value(current.current.edges) : value;
+ dispatch({ type: 'edges', value: next });
+ }, [dispatch]);
+ const markUnsaved = useCallback(() => dispatch({ type: 'dirty' }), [dispatch]);
+ const onNodesChange = useCallback((changes) => {
+ setNodes((nodes) => applyNodeChanges(changes, nodes));
+ if (changes.some((change) => ['position', 'remove', 'add', 'replace'].includes(change.type))) markUnsaved();
+ if (changes.some((change) => change.type === 'position' && change.dragging === false)) {
+ dispatch({ type: 'checkpoint', nodes: current.current.nodes, edges: current.current.edges });
+ }
+ }, [setNodes, markUnsaved, dispatch]);
+ const onEdgesChange = useCallback((changes) => {
+ setEdges((edges) => applyEdgeChanges(changes, edges));
+ if (changes.some((change) => change.type !== 'select')) markUnsaved();
+ }, [setEdges, markUnsaved]);
+ const saveToHistory = useCallback((nodes, edges) => dispatch({ type: 'checkpoint', nodes, edges }), [dispatch]);
+ const resetDraft = useCallback((nodes, edges) => dispatch({ type: 'reset', nodes, edges }), [dispatch]);
+ const updateNodeData = useCallback((id, data) => {
+ const revision = current.current.revision;
+ return dispatch({ type: 'nodeData', id, data }).revision !== revision;
+ }, [dispatch]);
+ const captureSave = useCallback(() => ({ ...cloneGraphDraft(current.current.nodes, current.current.edges), revision: current.current.revision, generation: current.current.generation }), []);
+ const acknowledgeSave = useCallback((ticket) => {
+ const next = dispatch({ type: 'saved', ticket });
+ return ticket.generation === next.generation && next.revision === next.savedRevision;
+ }, [dispatch]);
+ const undo = useCallback(() => dispatch({ type: 'undo' }), [dispatch]);
+ const redo = useCallback(() => dispatch({ type: 'redo' }), [dispatch]);
+ return { ...state, setNodes, setEdges, onNodesChange, onEdgesChange, markUnsaved, saveToHistory, resetDraft, updateNodeData, captureSave, acknowledgeSave, undo, redo, hasUnsavedChanges: state.revision !== state.savedRevision };
+}
diff --git a/src/i18n/locales/cs.json b/src/i18n/locales/cs.json
index 3ad58a9..34c2c4d 100644
--- a/src/i18n/locales/cs.json
+++ b/src/i18n/locales/cs.json
@@ -1,488 +1,592 @@
{
- "app": {
- "title": "Mountea Dialoguer",
- "subtitle": "Správa dialogů jednoduše a rychle",
- "version": "Verze {{version}}"
- },
- "common": {
- "save": "Uložit",
- "saving": "Ukládání...",
- "cancel": "Zrušit",
- "delete": "Smazat",
- "deleting": "Mazání...",
- "edit": "Upravit",
- "create": "Vytvořit",
- "creating": "Vytváření...",
- "import": "Importovat",
- "export": "Exportovat",
- "search": "Hledat",
- "close": "Zavířit",
- "apply": "Použít změny",
- "loading": "Načítání...",
- "error": "Chyba",
- "success": "Úspěch",
- "confirm": "Potvrdit",
- "back": "Zpět",
- "new": "Nový",
- "add": "Přidat",
- "manage": "Spravovat",
- "true": "True",
- "false": "False",
- "time": {
- "justNow": "právě teď",
- "minutesAgo": "před {{count}}m",
- "minutesAgo_plural": "před {{count}}m",
- "hoursAgo": "před {{count}}h",
- "hoursAgo_plural": "před {{count}}h"
- }
- },
- "navigation": {
- "dashboard": "Přehled",
- "projects": "Mountea Dialoguer",
- "dialogues": "Dialogy",
- "participants": "Účastníci",
- "categories": "Kategorie",
- "decorators": "Dekorátory",
- "settings": "Nastavení"
- },
- "dashboard": {
- "diskUsage": "Využití disku"
- },
- "decorators": {
- "title": "Dekorátory",
- "addNew": "Nový Dekorátor",
- "createDescription": "Přidat nový dekorátor pro rozšíření funkčností dialogu",
- "updateDescription": "Aktualizovat detaily dekorátoru",
- "description": "Správa dekorátorů pro rozšíření základních fuknčností dialogu, jako je spuštění animace aj.",
- "name": "Název dekorátoru",
- "namePlaceholder": "Zde zadejte název dekorátoru",
- "type": "Typ",
- "typePlaceholder": "např. Audio, Visual, Animation",
- "panelTitle": "Dekorátory",
- "count": "{{count}} dekorátor",
- "count_plural": "{{count}} dekorátorů",
- "none": "Žádný dekorátor nenalezen",
- "unnamed": "Dekorátor bez názvu",
- "selectPlaceholder": "Vyberte dekorátor...",
- "enterProperty": "Zadejte {{name}}...",
- "validation": {
- "noWhitespace": "Název nesmí obsahovat mezery!"
- },
- "properties": {
- "title": "Proměnné",
- "add": "Přidat proměnnou",
- "empty": "Žádná proměnná není definována. Přidej nové proměnné pro rozšíření funkčnosti dekorátoru.",
- "name": "Název proměnné",
- "namePlaceholder": "např. Speed",
- "type": "Typ",
- "defaultValue": "Výchozí hodnota",
- "placeholders": {
- "bool": "true/false",
- "int": "0",
- "float": "0.0",
- "value": "hodnota"
- }
- },
- "types": {
- "string": "String",
- "integer": "Integer",
- "float": "Float",
- "boolean": "Boolean"
- },
- "noDecorators": "Žádný dekorátor nenalezen",
- "noDecoratorsDescription": "Přidej nové dekorátory pro rozšíření vlastností dialogu, jako je třeba spuštění animace, změna kamera aj.",
- "deleteTitle": "Smazat dekorátor",
- "deleteDescription": "Opravdu chete smazat {{name}}? Tato akce nelze vzít zpět."
- },
- "projects": {
- "title": "Projekty",
- "createNew": "Nový projekt",
- "createNewDescription": "Začít od nuly nebo naimportovat existující",
- "importProject": "Importovat projekt",
- "recentProjects": "Nedávné projekty",
- "allProjects": "Všechny projekty",
- "projectDetails": "Detaily projektu",
- "totalProjects": "Místní projekty",
- "totalDialogues": "Celkem dialogů",
- "diskUsage": "Využití disku",
- "noProjects": "Nebyly nalezeny Žádná projekty",
- "createFirst": "Vytvoř první projekt",
- "lastModified": "Naposledy upraveno",
- "createdDate": "Datum vytvoření",
- "created": "Vytvořil",
- "modified": "Upravil",
- "notFound": "Projekt nenalezen",
- "notFoundDescription": "Projekt, který hledáš, neexistuje nebo byl smazán",
- "projectName": "Název projektu",
- "projectDescription": "Popis projektu",
- "deleteProject": "Smazat projekt",
- "deleteConfirm": "Opravdu chceš smazat tento projekt? Tato akce nelze vzít zpět!"
- },
- "dialogues": {
- "title": "Dialogy",
- "createNew": "Vytvořit nový dialog",
- "createDescription": "Vytvoření nového dialogu",
- "dialogueName": "Název dialogu",
- "dialogueSettings": "Nastavení dialogu",
- "dialogueEditor": "Editor dialogu",
- "noDialogues": "Nebyly nalezeny žádné dialogy",
- "createFirst": "Vytvoř první projekt",
- "nodes": "Nodes",
- "nodeCount": "{{count}} node",
- "nodeCount_plural": "{{count}} nodes",
- "edges": "Edges",
- "autoSaved": "Automaticky uloženo",
- "unsavedChanges": "Neuložené změny"
- },
- "editor": {
- "menu": {
- "file": "Soubor",
- "edit": "Úprava",
- "view": "Zobrazení",
- "undo": "Zpět",
- "redo": "Vpřed",
- "showTour": "Zobrazit uvítací tour",
- "support": "Podpora"
- },
- "saveStatus": {
- "saved": "Všechny změny uloženy",
- "saving": "Ukládání...",
- "unsaved": "Neuložené změny",
- "error": "Ukládání selhalo"
- },
- "toolbar": {
- "undo": "Zpět",
- "redo": "Vpřed",
- "save": "Uložit",
- "export": "Export",
- "settings": "Nastavení",
- "darkMode": "Tmavý režim",
- "lightMode": "Světlý režim"
- },
- "nodeToolbar": {
- "autoLayout": "Auto Rozložení",
- "autoLayoutTooltip": "Automaticky rozloží nodes v grafu pomocí dagreho algoritmu",
- "npc": "NPC",
- "addNpcTooltip": "Přidat NPC node - přetáhni na plátno nebo jenom klikni",
- "player": "Player",
- "addPlayerTooltip": "Přidat Player node - přetáhni na plátno nebo jenom klikni",
- "return": "Return",
- "addReturnTooltip": "Přidat Return node - přetáhni na plátno nebo jenom klikni",
- "openChildGraph": "Open Child Graph",
- "addOpenChildGraphTooltip": "Přidat Open Child Graph node - přetáhni na plátno nebo jenom klikni",
- "complete": "Complete",
- "addCompleteTooltip": "Přidat Complete node - přetáhni na plátno nebo jenom klikni",
- "delay": "Delay",
- "addDelayTooltip": "Přidat Delay node - přetáhni na plátno nebo jenom klikni",
- "poweredBy": "Powered by"
- },
- "nodes": {
- "start": "Start",
- "startDescription": "Startovací čára celého dialogu",
- "npc": "NPC",
- "npcPlaceholder": "NPC text...",
- "player": "Player",
- "playerPlaceholder": "Text hráče ...",
- "complete": "Complete",
- "completePlaceholder": "Text na ukončení dialogu...",
- "delay": "Delay",
- "delayDescription": "Počkat {{count}} sekundu.",
- "delayDescription_plural": "Počkat {{count}} sekund.",
- "delaySeconds": "{{count}}",
- "delaySeconds_plural": "{{count}}",
- "return": "Return To",
- "returnTarget": "Cílová node:",
- "returnSelectNode": "Vyber node...",
- "openChildGraph": "Open Child Graph",
- "openChildGraphTarget": "Dialog:",
- "openChildGraphSelectDialogue": "Vyber dialog...",
- "rows": "{{count}} řádek",
- "rows_plural": "{{count}} řádků",
- "tooltip": {
- "participant": "Účastník",
- "decorators": "Dekorátory",
- "dialogueRows": "Dialogové řádky"
- }
- },
- "placeholder": {
- "addNode": "Přidat Node"
- },
- "nodeDetails": "Detaily Node",
- "speaker": "Mluvčí",
- "dialogueText": "Text dialogu",
- "audioClip": "Audio soubor",
- "decorators": "Dekorátory",
- "addDecorator": "Přidat dekorátor",
- "deleteCascadeTitle": "Smazat Node a všechny její dceřiné nodes?",
- "deleteCascadeDescription": "Smazáním této Node smažete i všechny dceřiné nodes. Tato akce nelze vzít zpět. Jedná se o omezení mobilního editoru.",
- "deleteCascadeConfirm": "Smazat vše",
- "deleteEdgeCascadeTitle": "Smazat hranu a následující uzly?",
- "deleteEdgeCascadeDescription": "Toto je jediné spojení k cílovému uzlu. Smazáním této hrany smažete i cílový uzel a všechny jeho potomky. Tato akce nelze vzít zpět.",
- "deleteEdgeCascadeConfirm": "Smazat hranu a uzly",
- "sections": {
- "nodeData": "Node data",
- "dialogueDetails": "Detaily dialogu",
- "nodeDecorators": "Node dekorátory",
- "displayName": "Název",
- "displayNamePlaceholder": "Zadat název...",
- "participant": "Mluvčí",
- "participantPlaceholder": "Vybrat mluvčího...",
- "nodeId": "Node ID",
- "selectionTitle": "Zkrácený název",
- "selectionTitlePlaceholder": "Zadat zkrácený název...",
- "dialogueRows": "Řádky dialogu",
- "targetNode": "Cílová node",
- "targetNodePlaceholder": "Vybrat cílovou node...",
- "targetDialogue": "Otevřít dialog",
- "targetDialoguePlaceholder": "Vybrat cílový dialog..."
- },
- "validation": {
- "saveWarningTitle": "Uloženo s varováním",
- "saveWarningDescription": "{{count}} node nemá vyplněná povinná data. Prosím, zkontrolujte.",
- "saveWarningDescription_plural": "{{count}} node nemají vyplněná povinná data. Prosím, zkontrolujte je.",
- "nodeMissingTitle": "Node vyžaduje opravu",
- "nodeMissingDescription": "\"{{name}}\" nemá vyplněná povinná data.",
- "focusNode": "Zaměřit node",
- "unsavedLeaveTitle": "Neuložené změny",
- "unsavedLeaveDescription": "Máš neuložené změny. Opuštěním stránky budou ztraceny. Pokračovat?"
- }
- },
- "participants": {
- "title": "Účastník",
- "addNew": "Přidat účastníka",
- "createDescription": "Přidat účastníka do projektu",
- "description": "Správa postav, které mohou vystupovat v dialozích v tomto projektu",
- "updateDescription": "Aktualizovat informace o účastníkovi",
- "top": "Nejpoužívanější účastnici",
- "inProject": "{{count}} účastník v projektu.",
- "inProject_plural": "{{count}} účastníků v projektu.",
- "name": "Název",
- "namePlaceholder": "Zadat název účastníka",
- "category": "Kategorie",
- "categoryPlaceholder": "např. NPC, Player, System",
- "avatar": "Avatar",
- "noParticipants": "Nebyli nalezení žádní účastníci",
- "noParticipantsDescription": "Přidat účastníka do projektu, aby mohl být použit jako postava v dialozích",
- "deleteTitle": "Smazat účastníka",
- "deleteDescription": "Opravdu chceš smazat účastníka{{name}}? Tato akce nelze vzít zpět."
- },
- "categories": {
- "title": "Kategorie",
- "addNew": "Přidat kategorii",
- "createDescription": "Přidat novou kategorii pro organizaci obsahu",
- "description": "Organizace a klasifikace dialogového obsahu",
- "updateDescription": "Aktualizovat informace o kategorii",
- "name": "Název kategorie",
- "namePlaceholder": "Zadat název kategorie",
- "parentCategory": "Nadřazená kategorie",
- "parentCategoryPlaceholder": "Vybrat nadřazenou kategorii (volitelné)",
- "noParent": "Žádná (Kořenová kategorie)",
- "parentCategoryHelp": "Podobné jako Unreal Engine Gameplay Tags – vytvářejte hierarchické kategorie",
- "noCategories": "Zatím žádné kategorie",
- "noCategoriesDescription": "Přidejte kategorie pro organizaci dialogů a obsahu",
- "deleteTitle": "Smazat kategorii",
- "deleteDescription": "Opravdu chcete smazat {{name}}? Tato akce nelze vzít zpět."
- },
- "settings": {
- "title": "Nastavení",
- "projectDescription": "Konfigurace nastavení projektu a metadat",
- "projectInfo": "Informace o projektu",
- "projectInfoDescription": "Upravit název, popis a verzi projektu",
- "descriptionPlaceholder": "Zadat popis projektu",
- "version": "Verze",
- "metadata": "Metadata",
- "metadataDescription": "Zobrazit metadata projektu a časové údaje",
- "projectId": "Project ID",
- "dataManagement": "Správa dat",
- "dataManagementDescription": "Export nebo import dat projektu",
- "exportProject": "Export projektu",
- "dangerZone": "Nebezpečná zóna",
- "dangerZoneDescription": "Nevratné akce ovlivňující projekt",
- "language": "Jazyk",
- "theme": "Motiv",
- "lightMode": "Světlý",
- "darkMode": "Tmavý",
- "systemMode": "Systémový"
- },
- "settingsCommand": {
- "title": "Paleta nastavení",
- "description": "Kontextová nastavení pro tuto obrazovku.",
- "searchPlaceholder": "Hledat nastavení nebo zkratky...",
- "noResults": "Žádné výsledky.",
- "groups": {
- "actions": "Akce",
- "project": "Nastavení projektu",
- "dialogue": "Nastavení dialogu",
- "shortcuts": "Zkratky"
- },
- "actions": {
- "openProjectSettings": "Otevřít nastavení projektu",
- "openDialogueSettings": "Otevřít nastavení dialogu",
- "openSettings": "Otevřít nastavení"
- },
- "items": {
- "dialogueInfo": "Informace o dialogu",
- "dialogueMetadata": "Metadata dialogu",
- "dialogueExport": "Export dialogu",
- "dialogueDanger": "Nebezpečná zóna",
- "save": "Uložit",
- "undo": "Zpět",
- "redo": "Vpřed",
- "commandPalette": "Otevřít příkazovou paletu",
- "showShortcuts": "Zobrazit klávesové zkratky",
- "deleteSelection": "Smazat výběr",
- "deselectAll": "Zrušit výběr"
- }
- },
- "sync": {
- "title": "Cloudová synchronizace",
- "description": "Připojte Google Drive pro synchronizaci projektů mezi zařízeními.",
- "providers": {
- "steam": "Steam",
- "googleDrive": "Google Drive",
- "none": "Nepřipojeno"
- },
- "steam": {
- "openOverlay": "Overlay",
- "profile": "Steam profil: {{account}}",
- "overlayReady": "Overlay je připravené. Můžete použít i Shift+Tab.",
- "overlayUnavailable": "Overlay není v této relaci dostupné.",
- "overlayUnavailableHint": "Spusťte aplikaci přes Steam, aby se overlay zapnulo."
- },
- "status": {
- "connected": "Připojeno",
- "connecting": "Připojování...",
- "syncing": "Synchronizace...",
- "error": "Chyba",
- "disconnected": "Odpojeno"
- },
- "connectedAs": "Připojeno jako {{account}}",
- "clientId": "Google Client ID",
- "clientIdPlaceholder": "your-client-id.apps.googleusercontent.com",
- "clientIdHint": "Vyžadováno pro přihlášení přes Google. Uloženo pouze na tomto zařízení.",
- "clientIdConfigured": "Client ID nastaveno",
- "clientIdHiddenHint": "Hodnota je skrytá pro zjednodušení.",
- "clientIdChange": "Změnit",
- "accountLabel": "Účet (zobrazení)",
- "accountPlaceholder": "nazev.schranky@seznam.cz",
- "passphrase": "Šifrovací heslo",
- "passphrasePlaceholder": "Zadat heslo",
- "passphraseHint": "Heslo šifruje data před opuštěním tohoto zařízení.",
- "remember": "Zapamatovat na tomto zařízení",
- "rememberHint": "Uloží heslo lokálně na tomto zařízení.",
- "promptDontShow": "Nezobrazovat při spuštění",
- "promptDontShowHint": "Skryje přihlašovací dialog při startu aplikace.",
- "connect": "Připojit Google Drive",
- "connecting": "Připojování...",
- "syncNow": "Synchronizovat nyní",
- "disconnect": "Odpojit",
- "errors": {
- "passphraseRequired": "Pro aktivaci synchronizace je vyžadováno heslo.",
- "missingClientId": "Chybí Google Client ID. Přidejte jej výše pro pokračování.",
- "oauthFailed": "Přihlášení přes Google selhalo. Zkuste to znovu.",
- "popupBlocked": "Popup okno bylo blokováno. Povolte popupy a zkuste to znovu.",
- "syncFailed": "Synchronizace selhala. Zkontrolujte připojení a zkuste to znovu.",
- "redirectUriMismatch": "Nesoulad Redirect URI. Ujistěte se, že oauth-callback.html je povoleno.",
- "invalidGrant": "Autorizační kód vypršel nebo je neplatný. Zkuste to znovu.",
- "tokenExpired": "Relace vypršela. Připojte Google Drive znovu."
- },
- "pullTitle": "Synchronizace projektu",
- "pullDescription": "Kontrola Google Drive a aplikace nejnovějších změn.",
- "bulkTitle": "Synchronizace cloudových projektů",
- "bulkDescription": "Načítání a aplikace cloudových dat.",
- "bulkProgress": "Synchronizováno {{current}} z {{total}} projektů.",
- "progress": {
- "checking": "Kontrola změn",
- "downloading": "Stahování snapshotu",
- "decrypting": "Dešifrování dat",
- "applying": "Aplikace aktualizací"
- }
- },
- "tour": {
- "dashboard": {
- "welcomeTitle": "Vítejte v Mountea Dialoguer!",
- "welcomeText": "Pojďme si dát krátkou prohlídku pro rychlý start. Po ní uvidíte nastavení cloudové synchronizace.",
- "exampleTitle": "Začněte ukázkovým projektem",
- "exampleText": "Vytvořte si připravený vzorový projekt s již importovaným dialogem.",
- "exampleAction": "Vytvořit ukázkový projekt",
- "createTitle": "Vytvořte první projekt",
- "createText": "Klikněte sem pro vytvoření nového projektu. Projekty pomáhají organizovat vaše dialogy.",
- "searchTitle": "Rychlé hledání",
- "searchText": "Použijte vyhledávání pro rychlé nalezení projektů a dialogů.",
- "communityTitle": "Podpora, komunita a Unreal Engine",
- "communityText": "Projekt můžete podpořit, připojit se na Discord a používat integraci pro Unreal Engine.",
- "communitySupport": "Podpořit na GitHub Sponsors",
- "communityDiscord": "Připojit se na Discord",
- "communityUnreal": "Unreal Engine (Fab)"
- },
- "editor": {
- "welcomeTitle": "Prohlídka editoru dialogů",
- "welcomeText": "Naučte se vytvářet interaktivní dialogy.",
- "nodeToolbarTitle": "Panel nodes",
- "nodeToolbarText": "Přetahujte tyto nodes na plátno pro vytvoření struktury dialogu.",
- "canvasTitle": "Plátno",
- "canvasText": "Zde vytváříte strom dialogu. Propojujte nodes a definujte průběh konverzace.",
- "saveTitle": "Uložení práce",
- "saveText": "Nezapomeňte ukládat. Vaše změny jsou ukládány lokálně."
- },
- "controls": {
- "back": "Zpět",
- "close": "Zavřít",
- "last": "Dokončit",
- "next": "Další",
- "nextWithProgress": "Další (Krok {step} z {steps})",
- "skip": "Přeskočit prohlídku"
- }
- },
- "legal": {
- "navAriaLabel": "Právní a podpůrné odkazy",
- "mobileBubbleAriaLabel": "Otevřít právní a podpůrné odkazy",
- "paletteGroup": "Právní",
- "palettePlaceholder": "Hledat právní a podpůrné akce...",
- "effectiveDate": "Datum účinnosti: 14. února 2026",
- "links": {
- "terms": "Podmínky služby",
- "data": "Zásady ochrany dat",
- "support": "Podpora",
- "supportAriaLabel": "Podpora na Discordu"
- },
- "tooltips": {
- "terms": "Otevřít Podmínky služby",
- "data": "Otevřít Zásady ochrany dat",
- "support": "Otevřít možnosti podpory"
- },
- "supportConfirm": {
- "title": "Otevřít podporu na Discordu?",
- "description": "Po kliknutí na Ano budete přesměrováni na Discord server.",
- "yes": "Ano",
- "no": "Ne"
- },
- "terms": {
- "title": "Podmínky služby",
- "paragraphs": {
- "1": "Používáním této aplikace souhlasíte s jejím používáním v souladu s platnými zákony a těmito podmínkami.",
- "2": "Odpovídáte za správnost a zákonnost obsahu, který v tomto nástroji vytváříte, importujete nebo exportujete.",
- "3": "Software je poskytován tak, jak je, bez záruk jakéhokoli druhu. Používáte jej na vlastní riziko.",
- "4": "Podpora je poskytována pouze na bázi best effort. Neexistuje smluvní nárok na podporu, čas reakce ani vyřešení problému.",
- "5": "Pro komunitní podporu použijte Discord server:",
- "6": "Tyto podmínky můžeme časem aktualizovat. Pokračováním v používání aplikace souhlasíte s aktualizovanými podmínkami.",
- "7": "Pokud s těmito podmínkami nesouhlasíte, přestaňte aplikaci používat."
- }
- },
- "data": {
- "title": "Zásady ochrany dat",
- "paragraphs": {
- "1": "Tato aplikace ukládá data projektu primárně lokálně v úložišti prohlížeče nebo zařízení.",
- "2": "Po zapnutí volitelné synchronizace mohou být data přenesena do nakonfigurovaných úložišť třetích stran.",
- "3": "Vy určujete, jaké informace jsou zadány, exportovány a synchronizovány. Citlivá data ukládejte pouze s odpovídajícím zabezpečením.",
- "4": "Doporučujeme používat bezpečná zařízení, ochranu účtů a šifrování, kde je dostupné.",
- "5": "Pokračováním v používání této aplikace berete tento model zpracování dat na vědomí."
- }
- }
- },
- "validation": {
- "required": "Toto pole je povinné",
- "invalidName": "Neplatný název",
- "nameTaken": "Tento název se již používá"
- }
+ "app": {
+ "title": "Mountea Dialoguer",
+ "subtitle": "Správa dialogů jednoduše a rychle",
+ "version": "Verze {{version}}"
+ },
+ "common": {
+ "save": "Uložit",
+ "saving": "Ukládání...",
+ "cancel": "Zrušit",
+ "delete": "Smazat",
+ "deleting": "Mazání...",
+ "edit": "Upravit",
+ "create": "Vytvořit",
+ "creating": "Vytváření...",
+ "import": "Importovat",
+ "export": "Exportovat",
+ "search": "Hledat",
+ "close": "Zavířit",
+ "apply": "Použít změny",
+ "loading": "Načítání...",
+ "error": "Chyba",
+ "success": "Úspěch",
+ "confirm": "Potvrdit",
+ "back": "Zpět",
+ "new": "Nový",
+ "add": "Přidat",
+ "manage": "Spravovat",
+ "true": "True",
+ "false": "False",
+ "time": {
+ "justNow": "právě teď",
+ "minutesAgo": "před {{count}}m",
+ "minutesAgo_plural": "před {{count}}m",
+ "hoursAgo": "před {{count}}h",
+ "hoursAgo_plural": "před {{count}}h"
+ },
+ "retry": "Zkusit znovu"
+ },
+ "navigation": {
+ "dashboard": "Přehled",
+ "projects": "Mountea Dialoguer",
+ "dialogues": "Dialogy",
+ "participants": "Účastníci",
+ "categories": "Kategorie",
+ "decorators": "Dekorátory",
+ "settings": "Nastavení"
+ },
+ "dashboard": {
+ "diskUsage": "Využití úložiště webu"
+ },
+ "decorators": {
+ "title": "Dekorátory",
+ "addNew": "Nový Dekorátor",
+ "createDescription": "Přidat nový dekorátor pro rozšíření funkčností dialogu",
+ "updateDescription": "Aktualizovat detaily dekorátoru",
+ "description": "Správa dekorátorů pro rozšíření základních fuknčností dialogu, jako je spuštění animace aj.",
+ "name": "Název dekorátoru",
+ "namePlaceholder": "Zde zadejte název dekorátoru",
+ "type": "Typ",
+ "typePlaceholder": "např. Audio, Visual, Animation",
+ "panelTitle": "Dekorátory",
+ "count": "{{count}} dekorátor",
+ "count_plural": "{{count}} dekorátorů",
+ "none": "Žádný dekorátor nenalezen",
+ "unnamed": "Dekorátor bez názvu",
+ "selectPlaceholder": "Vyberte dekorátor...",
+ "enterProperty": "Zadejte {{name}}...",
+ "validation": {
+ "noWhitespace": "Název nesmí obsahovat mezery!"
+ },
+ "properties": {
+ "title": "Proměnné",
+ "add": "Přidat proměnnou",
+ "empty": "Žádná proměnná není definována. Přidej nové proměnné pro rozšíření funkčnosti dekorátoru.",
+ "name": "Název proměnné",
+ "namePlaceholder": "např. Speed",
+ "type": "Typ",
+ "defaultValue": "Výchozí hodnota",
+ "placeholders": {
+ "bool": "true/false",
+ "int": "0",
+ "float": "0.0",
+ "value": "hodnota"
+ }
+ },
+ "types": {
+ "string": "String",
+ "integer": "Integer",
+ "float": "Float",
+ "boolean": "Boolean"
+ },
+ "noDecorators": "Žádný dekorátor nenalezen",
+ "noDecoratorsDescription": "Přidej nové dekorátory pro rozšíření vlastností dialogu, jako je třeba spuštění animace, změna kamera aj.",
+ "deleteTitle": "Smazat dekorátor",
+ "deleteDescription": "Opravdu chete smazat {{name}}? Tato akce nelze vzít zpět."
+ },
+ "projects": {
+ "title": "Projekty",
+ "createNew": "Nový projekt",
+ "createNewDescription": "Začít od nuly nebo naimportovat existující",
+ "importProject": "Importovat projekt",
+ "recentProjects": "Nedávné projekty",
+ "allProjects": "Všechny projekty",
+ "projectDetails": "Detaily projektu",
+ "totalProjects": "Místní projekty",
+ "totalDialogues": "Celkem dialogů",
+ "diskUsage": "Využití disku",
+ "noProjects": "Nebyly nalezeny Žádná projekty",
+ "createFirst": "Vytvoř první projekt",
+ "lastModified": "Naposledy upraveno",
+ "createdDate": "Datum vytvoření",
+ "created": "Vytvořil",
+ "modified": "Upravil",
+ "notFound": "Projekt nenalezen",
+ "notFoundDescription": "Projekt, který hledáš, neexistuje nebo byl smazán",
+ "projectName": "Název projektu",
+ "projectDescription": "Popis projektu",
+ "deleteProject": "Smazat projekt",
+ "deleteConfirm": "Opravdu chceš smazat tento projekt? Tato akce nelze vzít zpět!"
+ },
+ "dialogues": {
+ "title": "Dialogy",
+ "createNew": "Vytvořit nový dialog",
+ "createDescription": "Vytvoření nového dialogu",
+ "dialogueName": "Název dialogu",
+ "dialogueSettings": "Nastavení dialogu",
+ "dialogueEditor": "Editor dialogu",
+ "noDialogues": "Nebyly nalezeny žádné dialogy",
+ "createFirst": "Vytvoř první projekt",
+ "nodes": "Nodes",
+ "nodeCount": "{{count}} node",
+ "nodeCount_plural": "{{count}} nodes",
+ "edges": "Edges",
+ "autoSaved": "Automaticky uloženo",
+ "unsavedChanges": "Neuložené změny"
+ },
+ "editor": {
+ "menu": {
+ "file": "Soubor",
+ "edit": "Úprava",
+ "view": "Zobrazení",
+ "undo": "Zpět",
+ "redo": "Vpřed",
+ "showTour": "Zobrazit uvítací tour",
+ "support": "Podpora"
+ },
+ "saveStatus": {
+ "saved": "Všechny změny uloženy",
+ "saving": "Ukládání...",
+ "unsaved": "Neuložené změny",
+ "error": "Ukládání selhalo"
+ },
+ "toolbar": {
+ "undo": "Zpět",
+ "redo": "Vpřed",
+ "save": "Uložit",
+ "export": "Export",
+ "settings": "Nastavení",
+ "darkMode": "Tmavý režim",
+ "lightMode": "Světlý režim"
+ },
+ "nodeToolbar": {
+ "autoLayout": "Auto Rozložení",
+ "autoLayoutTooltip": "Automaticky rozloží nodes v grafu pomocí dagreho algoritmu",
+ "npc": "NPC",
+ "addNpcTooltip": "Přidat NPC node - přetáhni na plátno nebo jenom klikni",
+ "player": "Player",
+ "addPlayerTooltip": "Přidat Player node - přetáhni na plátno nebo jenom klikni",
+ "return": "Return",
+ "addReturnTooltip": "Přidat Return node - přetáhni na plátno nebo jenom klikni",
+ "openChildGraph": "Open Child Graph",
+ "addOpenChildGraphTooltip": "Přidat Open Child Graph node - přetáhni na plátno nebo jenom klikni",
+ "complete": "Complete",
+ "addCompleteTooltip": "Přidat Complete node - přetáhni na plátno nebo jenom klikni",
+ "delay": "Delay",
+ "addDelayTooltip": "Přidat Delay node - přetáhni na plátno nebo jenom klikni",
+ "poweredBy": "Powered by",
+ "recenter": "Vystředit graf",
+ "backToStart": "Zaměřit uzel Start",
+ "recenterTooltip": "Zobrazit celý graf",
+ "startFocusTooltip": "Zaměřit uzel Start"
+ },
+ "nodes": {
+ "start": "Start",
+ "startDescription": "Startovací čára celého dialogu",
+ "npc": "NPC",
+ "npcPlaceholder": "NPC text...",
+ "player": "Player",
+ "playerPlaceholder": "Text hráče ...",
+ "complete": "Complete",
+ "completePlaceholder": "Text na ukončení dialogu...",
+ "delay": "Delay",
+ "delayDescription": "Počkat {{count}} sekundu.",
+ "delayDescription_plural": "Počkat {{count}} sekund.",
+ "delaySeconds": "{{count}}",
+ "delaySeconds_plural": "{{count}}",
+ "return": "Return To",
+ "returnTarget": "Cílová node:",
+ "returnSelectNode": "Vyber node...",
+ "openChildGraph": "Open Child Graph",
+ "openChildGraphTarget": "Dialog:",
+ "openChildGraphSelectDialogue": "Vyber dialog...",
+ "rows": "{{count}} řádek",
+ "rows_plural": "{{count}} řádků",
+ "tooltip": {
+ "participant": "Účastník",
+ "decorators": "Dekorátory",
+ "dialogueRows": "Dialogové řádky"
+ }
+ },
+ "placeholder": {
+ "addNode": "Přidat Node"
+ },
+ "nodeDetails": "Detaily Node",
+ "speaker": "Mluvčí",
+ "dialogueText": "Text dialogu",
+ "audioClip": "Audio soubor",
+ "decorators": "Dekorátory",
+ "addDecorator": "Přidat dekorátor",
+ "deleteCascadeTitle": "Smazat Node a všechny její dceřiné nodes?",
+ "deleteCascadeDescription": "Smazáním této Node smažete i všechny dceřiné nodes. Tato akce nelze vzít zpět. Jedná se o omezení mobilního editoru.",
+ "deleteCascadeConfirm": "Smazat vše",
+ "deleteEdgeCascadeTitle": "Smazat hranu a následující uzly?",
+ "deleteEdgeCascadeDescription": "Toto je jediné spojení k cílovému uzlu. Smazáním této hrany smažete i cílový uzel a všechny jeho potomky. Tato akce nelze vzít zpět.",
+ "deleteEdgeCascadeConfirm": "Smazat hranu a uzly",
+ "sections": {
+ "nodeData": "Node data",
+ "dialogueDetails": "Detaily dialogu",
+ "nodeDecorators": "Node dekorátory",
+ "displayName": "Název",
+ "displayNamePlaceholder": "Zadat název...",
+ "participant": "Mluvčí",
+ "participantPlaceholder": "Vybrat mluvčího...",
+ "nodeId": "Node ID",
+ "selectionTitle": "Zkrácený název",
+ "selectionTitlePlaceholder": "Zadat zkrácený název...",
+ "dialogueRows": "Řádky dialogu",
+ "targetNode": "Cílová node",
+ "targetNodePlaceholder": "Vybrat cílovou node...",
+ "targetDialogue": "Otevřít dialog",
+ "targetDialoguePlaceholder": "Vybrat cílový dialog..."
+ },
+ "validation": {
+ "saveWarningTitle": "Uloženo s varováním",
+ "saveWarningDescription": "{{count}} node nemá vyplněná povinná data. Prosím, zkontrolujte.",
+ "saveWarningDescription_plural": "{{count}} node nemají vyplněná povinná data. Prosím, zkontrolujte je.",
+ "nodeMissingTitle": "Node vyžaduje opravu",
+ "nodeMissingDescription": "\"{{name}}\" nemá vyplněná povinná data.",
+ "focusNode": "Zaměřit node",
+ "unsavedLeaveTitle": "Neuložené změny",
+ "unsavedLeaveDescription": "Máš neuložené změny. Opuštěním stránky budou ztraceny. Pokračovat?"
+ }
+ },
+ "participants": {
+ "title": "Účastník",
+ "addNew": "Přidat účastníka",
+ "createDescription": "Přidat účastníka do projektu",
+ "description": "Správa postav, které mohou vystupovat v dialozích v tomto projektu",
+ "updateDescription": "Aktualizovat informace o účastníkovi",
+ "top": "Nejpoužívanější účastnici",
+ "inProject": "{{count}} účastník v projektu.",
+ "inProject_plural": "{{count}} účastníků v projektu.",
+ "name": "Název",
+ "namePlaceholder": "Zadat název účastníka",
+ "category": "Kategorie",
+ "categoryPlaceholder": "např. NPC, Player, System",
+ "avatar": "Avatar",
+ "noParticipants": "Nebyli nalezení žádní účastníci",
+ "noParticipantsDescription": "Přidat účastníka do projektu, aby mohl být použit jako postava v dialozích",
+ "deleteTitle": "Smazat účastníka",
+ "deleteDescription": "Opravdu chceš smazat účastníka{{name}}? Tato akce nelze vzít zpět."
+ },
+ "categories": {
+ "title": "Kategorie",
+ "addNew": "Přidat kategorii",
+ "createDescription": "Přidat novou kategorii pro organizaci obsahu",
+ "description": "Organizace a klasifikace dialogového obsahu",
+ "updateDescription": "Aktualizovat informace o kategorii",
+ "name": "Název kategorie",
+ "namePlaceholder": "Zadat název kategorie",
+ "parentCategory": "Nadřazená kategorie",
+ "parentCategoryPlaceholder": "Vybrat nadřazenou kategorii (volitelné)",
+ "noParent": "Žádná (Kořenová kategorie)",
+ "parentCategoryHelp": "Podobné jako Unreal Engine Gameplay Tags – vytvářejte hierarchické kategorie",
+ "noCategories": "Zatím žádné kategorie",
+ "noCategoriesDescription": "Přidejte kategorie pro organizaci dialogů a obsahu",
+ "deleteTitle": "Smazat kategorii",
+ "deleteDescription": "Opravdu chcete smazat {{name}}? Tato akce nelze vzít zpět."
+ },
+ "settings": {
+ "title": "Nastavení",
+ "projectDescription": "Konfigurace nastavení projektu a metadat",
+ "projectInfo": "Informace o projektu",
+ "projectInfoDescription": "Upravit název, popis a verzi projektu",
+ "descriptionPlaceholder": "Zadat popis projektu",
+ "version": "Verze",
+ "metadata": "Metadata",
+ "metadataDescription": "Zobrazit metadata projektu a časové údaje",
+ "projectId": "Project ID",
+ "dataManagement": "Správa dat",
+ "dataManagementDescription": "Export nebo import dat projektu",
+ "exportProject": "Export projektu",
+ "dangerZone": "Nebezpečná zóna",
+ "dangerZoneDescription": "Nevratné akce ovlivňující projekt",
+ "language": "Jazyk",
+ "theme": "Motiv",
+ "lightMode": "Světlý",
+ "darkMode": "Tmavý",
+ "systemMode": "Systémový"
+ },
+ "settingsCommand": {
+ "title": "Paleta nastavení",
+ "description": "Kontextová nastavení pro tuto obrazovku.",
+ "searchPlaceholder": "Hledat nastavení nebo zkratky...",
+ "noResults": "Žádné výsledky.",
+ "groups": {
+ "actions": "Akce",
+ "project": "Nastavení projektu",
+ "dialogue": "Nastavení dialogu",
+ "shortcuts": "Zkratky"
+ },
+ "actions": {
+ "openProjectSettings": "Otevřít nastavení projektu",
+ "openDialogueSettings": "Otevřít nastavení dialogu",
+ "openSettings": "Otevřít nastavení"
+ },
+ "items": {
+ "dialogueInfo": "Informace o dialogu",
+ "dialogueMetadata": "Metadata dialogu",
+ "dialogueExport": "Export dialogu",
+ "dialogueDanger": "Nebezpečná zóna",
+ "save": "Uložit",
+ "undo": "Zpět",
+ "redo": "Vpřed",
+ "commandPalette": "Otevřít příkazovou paletu",
+ "showShortcuts": "Zobrazit klávesové zkratky",
+ "deleteSelection": "Smazat výběr",
+ "deselectAll": "Zrušit výběr"
+ }
+ },
+ "sync": {
+ "title": "Cloudová synchronizace",
+ "description": "Připojte Google Drive pro synchronizaci projektů mezi zařízeními.",
+ "providers": {
+ "steam": "Steam",
+ "googleDrive": "Google Drive",
+ "none": "Nepřipojeno"
+ },
+ "steam": {
+ "openOverlay": "Overlay",
+ "profile": "Steam profil: {{account}}",
+ "overlayReady": "Overlay je připravené. Můžete použít i Shift+Tab.",
+ "overlayUnavailable": "Overlay není v této relaci dostupné.",
+ "overlayUnavailableHint": "Spusťte aplikaci přes Steam, aby se overlay zapnulo."
+ },
+ "status": {
+ "connected": "Připojeno",
+ "connecting": "Připojování...",
+ "syncing": "Synchronizace...",
+ "error": "Chyba",
+ "disconnected": "Odpojeno"
+ },
+ "connectedAs": "Připojeno jako {{account}}",
+ "clientId": "Google Client ID",
+ "clientIdPlaceholder": "your-client-id.apps.googleusercontent.com",
+ "clientIdHint": "Vyžadováno pro přihlášení přes Google. Uloženo pouze na tomto zařízení.",
+ "clientIdConfigured": "Client ID nastaveno",
+ "clientIdHiddenHint": "Hodnota je skrytá pro zjednodušení.",
+ "clientIdChange": "Změnit",
+ "accountLabel": "Účet (zobrazení)",
+ "accountPlaceholder": "nazev.schranky@seznam.cz",
+ "passphrase": "Šifrovací heslo",
+ "passphrasePlaceholder": "Zadat heslo",
+ "passphraseHint": "Heslo šifruje data před opuštěním tohoto zařízení.",
+ "remember": "Zapamatovat na tomto zařízení",
+ "rememberHint": "Uloží heslo lokálně na tomto zařízení.",
+ "promptDontShow": "Nezobrazovat při spuštění",
+ "promptDontShowHint": "Skryje přihlašovací dialog při startu aplikace.",
+ "connect": "Připojit Google Drive",
+ "connecting": "Připojování...",
+ "syncNow": "Synchronizovat nyní",
+ "disconnect": "Odpojit",
+ "errors": {
+ "passphraseRequired": "Pro aktivaci synchronizace je vyžadováno heslo.",
+ "missingClientId": "Chybí Google Client ID. Přidejte jej výše pro pokračování.",
+ "oauthFailed": "Přihlášení přes Google selhalo. Zkuste to znovu.",
+ "popupBlocked": "Popup okno bylo blokováno. Povolte popupy a zkuste to znovu.",
+ "syncFailed": "Synchronizace selhala. Zkontrolujte připojení a zkuste to znovu.",
+ "redirectUriMismatch": "Nesoulad Redirect URI. Ujistěte se, že oauth-callback.html je povoleno.",
+ "invalidGrant": "Autorizační kód vypršel nebo je neplatný. Zkuste to znovu.",
+ "tokenExpired": "Relace vypršela. Připojte Google Drive znovu."
+ },
+ "pullTitle": "Synchronizace projektu",
+ "pullDescription": "Kontrola Google Drive a aplikace nejnovějších změn.",
+ "bulkTitle": "Synchronizace cloudových projektů",
+ "bulkDescription": "Načítání a aplikace cloudových dat.",
+ "bulkProgress": "Synchronizováno {{current}} z {{total}} projektů.",
+ "progress": {
+ "checking": "Kontrola změn",
+ "downloading": "Stahování snapshotu",
+ "decrypting": "Dešifrování dat",
+ "applying": "Aplikace aktualizací"
+ }
+ },
+ "tour": {
+ "dashboard": {
+ "welcomeTitle": "Vítejte v Mountea Dialoguer!",
+ "welcomeText": "Pojďme si dát krátkou prohlídku pro rychlý start. Po ní uvidíte nastavení cloudové synchronizace.",
+ "exampleTitle": "Začněte ukázkovým projektem",
+ "exampleText": "Vytvořte si připravený vzorový projekt s již importovaným dialogem.",
+ "exampleAction": "Vytvořit ukázkový projekt",
+ "createTitle": "Vytvořte první projekt",
+ "createText": "Klikněte sem pro vytvoření nového projektu. Projekty pomáhají organizovat vaše dialogy.",
+ "searchTitle": "Rychlé hledání",
+ "searchText": "Použijte vyhledávání pro rychlé nalezení projektů a dialogů.",
+ "communityTitle": "Podpora, komunita a Unreal Engine",
+ "communityText": "Projekt můžete podpořit, připojit se na Discord a používat integraci pro Unreal Engine.",
+ "communitySupport": "Podpořit na GitHub Sponsors",
+ "communityDiscord": "Připojit se na Discord",
+ "communityUnreal": "Unreal Engine (Fab)"
+ },
+ "editor": {
+ "welcomeTitle": "Prohlídka editoru dialogů",
+ "welcomeText": "Naučte se vytvářet interaktivní dialogy.",
+ "nodeToolbarTitle": "Panel nodes",
+ "nodeToolbarText": "Přetahujte tyto nodes na plátno pro vytvoření struktury dialogu.",
+ "canvasTitle": "Plátno",
+ "canvasText": "Zde vytváříte strom dialogu. Propojujte nodes a definujte průběh konverzace.",
+ "saveTitle": "Uložení práce",
+ "saveText": "Nezapomeňte ukládat. Vaše změny jsou ukládány lokálně."
+ },
+ "controls": {
+ "back": "Zpět",
+ "close": "Zavřít",
+ "last": "Dokončit",
+ "next": "Další",
+ "nextWithProgress": "Další (Krok {step} z {steps})",
+ "skip": "Přeskočit prohlídku"
+ }
+ },
+ "legal": {
+ "navAriaLabel": "Právní a podpůrné odkazy",
+ "mobileBubbleAriaLabel": "Otevřít právní a podpůrné odkazy",
+ "paletteGroup": "Právní",
+ "palettePlaceholder": "Hledat právní a podpůrné akce...",
+ "effectiveDate": "Datum účinnosti: 14. února 2026",
+ "links": {
+ "terms": "Podmínky služby",
+ "data": "Zásady ochrany dat",
+ "support": "Podpora",
+ "supportAriaLabel": "Podpora na Discordu"
+ },
+ "tooltips": {
+ "terms": "Otevřít Podmínky služby",
+ "data": "Otevřít Zásady ochrany dat",
+ "support": "Otevřít možnosti podpory"
+ },
+ "supportConfirm": {
+ "title": "Otevřít podporu na Discordu?",
+ "description": "Po kliknutí na Ano budete přesměrováni na Discord server.",
+ "yes": "Ano",
+ "no": "Ne"
+ },
+ "terms": {
+ "title": "Podmínky služby",
+ "paragraphs": {
+ "1": "Používáním této aplikace souhlasíte s jejím používáním v souladu s platnými zákony a těmito podmínkami.",
+ "2": "Odpovídáte za správnost a zákonnost obsahu, který v tomto nástroji vytváříte, importujete nebo exportujete.",
+ "3": "Software je poskytován tak, jak je, bez záruk jakéhokoli druhu. Používáte jej na vlastní riziko.",
+ "4": "Podpora je poskytována pouze na bázi best effort. Neexistuje smluvní nárok na podporu, čas reakce ani vyřešení problému.",
+ "5": "Pro komunitní podporu použijte Discord server:",
+ "6": "Tyto podmínky můžeme časem aktualizovat. Pokračováním v používání aplikace souhlasíte s aktualizovanými podmínkami.",
+ "7": "Pokud s těmito podmínkami nesouhlasíte, přestaňte aplikaci používat."
+ }
+ },
+ "data": {
+ "title": "Zásady ochrany dat",
+ "paragraphs": {
+ "1": "Tato aplikace ukládá data projektu primárně lokálně v úložišti prohlížeče nebo zařízení.",
+ "2": "Po zapnutí volitelné synchronizace mohou být data přenesena do nakonfigurovaných úložišť třetích stran.",
+ "3": "Vy určujete, jaké informace jsou zadány, exportovány a synchronizovány. Citlivá data ukládejte pouze s odpovídajícím zabezpečením.",
+ "4": "Doporučujeme používat bezpečná zařízení, ochranu účtů a šifrování, kde je dostupné.",
+ "5": "Pokračováním v používání této aplikace berete tento model zpracování dat na vědomí."
+ }
+ }
+ },
+ "validation": {
+ "required": "Toto pole je povinné",
+ "invalidName": "Neplatný název",
+ "nameTaken": "Tento název se již používá"
+ },
+ "errors": {
+ "unexpectedTitle": "Neočekávaná chyba",
+ "unexpectedDescription": "Došlo k neočekávané chybě. Restartujte aplikaci a zkuste to znovu.",
+ "storageInitialization": "Místní úložiště se nepodařilo bezpečně otevřít. Původní databáze byla zachována.",
+ "actions": {
+ "load": "Data se nepodařilo načíst",
+ "create": "Položku se nepodařilo vytvořit",
+ "update": "Položku se nepodařilo aktualizovat",
+ "save": "Změny se nepodařilo uložit",
+ "delete": "Položku se nepodařilo odstranit",
+ "import": "Archiv se nepodařilo importovat",
+ "export": "Archiv se nepodařilo exportovat"
+ },
+ "details": {
+ "unexpected": "Operace selhala. Zkuste to znovu; pokud problém přetrvává, uchovejte zálohu a nahlaste jej.",
+ "stale": "Profil nebo projekt se změnil. Obnovte data a opakujte akci.",
+ "exists": "Cíl již existuje. Vyberte novou kopii nebo výslovné nahrazení.",
+ "missing": "Odkazovaná položka není dostupná. Zkontrolujte vybraný projekt.",
+ "referenced": "Před změnou nebo odstraněním položky odstraňte nebo přesměrujte tyto odkazy.",
+ "categoryCycle": "Kategorie nesmí být svým vlastním předkem. Vyberte jiného rodiče.",
+ "categoryDepth": "Kategorie podporují nejvýše pět úrovní. Vyberte vyššího rodiče.",
+ "reference": "Vyberte jednoznačnou položku patřící do tohoto projektu.",
+ "name": "V názvu použijte 1–16 písmen nebo číslic.",
+ "duplicateName": "Vyberte název jedinečný v tomto stromu kategorií.",
+ "definition": "Zkontrolujte názvy, typy a výchozí hodnoty vlastností; používané typy musí zůstat kompatibilní.",
+ "repair": "Opravte odkazy a překlady projektu a akci opakujte.",
+ "media": "Obnovte původní média a před dalším pokusem ověřte přílohu.",
+ "duplicateIdentity": "Zvolte odlišnou identitu duplicitního záznamu.",
+ "quota": "Úložiště je plné. Exportujte zálohu a uvolněte místo.",
+ "archiveLimit": "Zmenšete archiv nebo rozdělte projekt: limity jsou 25 MiB komprimovaně, 128 MiB po rozbalení, 5 MiB na položku JSON, 1 000 položek a 250 dialogů.",
+ "archiveCorrupt": "Archiv je poškozený nebo obsahuje neplatné cesty či záznamy. Obnovte ověřenou zálohu nebo jej znovu exportujte.",
+ "archiveUnsupported": "Tento formát archivu není podporován. Exportujte standardní archiv projektu nebo dialogu z kompatibilní verze aplikace."
+ }
+ },
+ "metrics": {
+ "payloadEstimate": "Odhad velikosti dat",
+ "unavailable": "Nedostupné",
+ "currentUser": "Aktuální uživatel"
+ },
+ "accessibility": {
+ "editorCommands": "Příkazy editoru",
+ "editSelection": "Upravit výběr",
+ "dialogueGraph": "Graf dialogu",
+ "zoom": "Přiblížení",
+ "zoomIn": "Přiblížit",
+ "zoomOut": "Oddálit",
+ "connectExisting": "Připojit k existujícímu uzlu",
+ "chooseConnection": "Vyberte uzel pro připojení",
+ "noConnections": "Nejsou dostupné žádné uzly pro připojení",
+ "chooseNode": "Vyberte uzel…",
+ "connect": "Připojit",
+ "nodeType": "Vyberte typ uzlu",
+ "nodeTypeDescription": "Vyberte typ uzlu, který chcete přidat do dialogu"
+ },
+ "archive": {
+ "importTitle": "Importovat archiv",
+ "copy": "Importovat jako novou kopii",
+ "replace": "Nahradit existující položku",
+ "target": "Položka k nahrazení",
+ "replaceWarning": "Vybraná položka a obsah jejího grafu budou nahrazeny. Chcete-li zachovat aktuální verzi, nejprve exportujte zálohu.",
+ "confirmReplace": "Nahradit vybranou položku",
+ "failed": "Import selhal. Zkontrolujte archiv a zkuste to znovu."
+ },
+ "recovery": {
+ "title": "Obnova projektu",
+ "description": "Před exportem nebo synchronizací opravte tyto odkazy. Původní záznamy zůstávají zachovány. Chybějící texty nebo média obnovte ze zálohy; aplikace je nedokáže rekonstruovat.",
+ "failed": "Obnova selhala. Zkontrolujte podrobnosti a zkuste to znovu.",
+ "details": "Technické podrobnosti",
+ "revalidate": "Znovu ověřit opravené záznamy",
+ "record": "Záznam",
+ "unknown": "neznámý",
+ "download": "Stáhnout původní podklady",
+ "replacement": "Náhrada za {{id}} {{row}}",
+ "choose": "Vyberte náhradu",
+ "assign": "Přiřadit",
+ "restoreDescription": "Obnovte tento původní uzel s novým identifikátorem a zamýšleným typem odlišným od Start. Před obnovením zkontrolujte jeho dialog a obsah.",
+ "prepare": "Připravit obnovený uzel",
+ "nodeJson": "JSON obnoveného uzlu",
+ "restore": "Obnovit zkontrolovaný uzel",
+ "globalTitle": "Zpráva o obnově",
+ "globalDescription": "Některé uložené záznamy vyžadují kontrolu. Původní podklady jsou zachovány.",
+ "orphanGuidance": "Tyto záznamy nemají dostupný nadřazený projekt. Stáhněte původní podklady a porovnejte je se zálohou před ruční obnovou do projektu. Vlastnictví nebylo přiřazeno automaticky.",
+ "openProject": "Otevřít nastavení obnovy projektu",
+ "reportDownload": "Stáhnout zprávu o obnově",
+ "refresh": "Aktualizovat zprávu"
+ },
+ "syncConflicts": {
+ "title": "Konflikty synchronizace",
+ "description": "Obě verze jsou zachovány. Vyberte, kterou verzi chcete ponechat.",
+ "incomplete": "Část historie revizí není dostupná. Před vyřešením znovu synchronizujte.",
+ "keepLocal": "Ponechat místní",
+ "keepRemote": "Ponechat vzdálenou",
+ "keepBoth": "Ponechat oba projekty",
+ "refresh": "Obnovit konflikty",
+ "selectRevision": "Konfliktní verze",
+ "revision": "Revize {{id}}",
+ "deleted": "Smazaný projekt",
+ "resolveError": "Tento konflikt se nepodařilo vyřešit."
+ }
}
diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json
index a6978ac..6a4adeb 100644
--- a/src/i18n/locales/de.json
+++ b/src/i18n/locales/de.json
@@ -31,7 +31,8 @@
"minutesAgo_plural": "vor {{count}} Min",
"hoursAgo": "vor {{count}} Std",
"hoursAgo_plural": "vor {{count}} Std"
- }
+ },
+ "retry": "Erneut versuchen"
},
"navigation": {
"dashboard": "Dashboard",
@@ -43,7 +44,7 @@
"settings": "Einstellungen"
},
"dashboard": {
- "diskUsage": "Speichernutzung"
+ "diskUsage": "Speichernutzung dieser Website"
},
"decorators": {
"title": "Dekoratoren",
@@ -172,7 +173,11 @@
"addCompleteTooltip": "Abschluss-Knoten hinzufügen – auf die Leinwand ziehen oder klicken",
"delay": "Verzögerung",
"addDelayTooltip": "Verzögerungs-Knoten hinzufügen – auf die Leinwand ziehen oder klicken",
- "poweredBy": "Bereitgestellt von"
+ "poweredBy": "Bereitgestellt von",
+ "recenter": "Graph zentrieren",
+ "backToStart": "Startknoten fokussieren",
+ "recenterTooltip": "Gesamten Graph anzeigen",
+ "startFocusTooltip": "Startknoten fokussieren"
},
"nodes": {
"start": "Start",
@@ -452,5 +457,104 @@
"required": "Dieses Feld ist erforderlich",
"invalidName": "Ungültiger Name",
"nameTaken": "Dieser Name wird bereits verwendet"
+ },
+ "errors": {
+ "unexpectedTitle": "Unerwarteter Fehler",
+ "unexpectedDescription": "Ein unerwarteter Fehler ist aufgetreten. Starten Sie die App neu und versuchen Sie es erneut.",
+ "storageInitialization": "Der lokale Speicher konnte nicht sicher geöffnet werden. Ihre ursprüngliche Datenbank wurde beibehalten.",
+ "actions": {
+ "load": "Daten konnten nicht geladen werden",
+ "create": "Element konnte nicht erstellt werden",
+ "update": "Element konnte nicht aktualisiert werden",
+ "save": "Änderungen konnten nicht gespeichert werden",
+ "delete": "Element konnte nicht gelöscht werden",
+ "import": "Archiv konnte nicht importiert werden",
+ "export": "Archiv konnte nicht exportiert werden"
+ },
+ "details": {
+ "unexpected": "Der Vorgang ist fehlgeschlagen. Versuchen Sie es erneut; sichern Sie bei weiteren Fehlern Ihre Sicherung und melden Sie das Problem.",
+ "stale": "Profil oder Projekt wurde geändert. Laden Sie die Daten neu und versuchen Sie es erneut.",
+ "exists": "Das Ziel existiert bereits. Wählen Sie eine neue Kopie oder ausdrücklich das Ersetzen.",
+ "missing": "Das referenzierte Element ist nicht verfügbar. Prüfen Sie das ausgewählte Projekt.",
+ "referenced": "Entfernen oder ändern Sie diese Verweise, bevor Sie das Element ändern oder löschen.",
+ "categoryCycle": "Eine Kategorie darf nicht ihr eigener Vorfahr sein. Wählen Sie ein anderes übergeordnetes Element.",
+ "categoryDepth": "Kategorien unterstützen höchstens fünf Ebenen. Wählen Sie eine höhere Ebene.",
+ "reference": "Wählen Sie ein eindeutiges Element dieses Projekts.",
+ "name": "Verwenden Sie 1–16 Buchstaben oder Ziffern für den Namen.",
+ "duplicateName": "Wählen Sie einen innerhalb dieses Kategoriebaums eindeutigen Namen.",
+ "definition": "Prüfen Sie Eigenschaftsnamen, Typen und Standardwerte; verwendete Typen müssen kompatibel bleiben.",
+ "repair": "Reparieren Sie Projektverweise und Übersetzungen und versuchen Sie es erneut.",
+ "media": "Stellen Sie die ursprünglichen Mediendaten wieder her und prüfen Sie den Anhang.",
+ "duplicateIdentity": "Wählen Sie eine andere Identität für den doppelten Datensatz.",
+ "quota": "Der Speicher ist voll. Exportieren Sie eine Sicherung und geben Sie Speicherplatz frei.",
+ "archiveLimit": "Verkleinern Sie das Archiv oder teilen Sie das Projekt: maximal 25 MiB komprimiert, 128 MiB entpackt, 5 MiB pro JSON-Eintrag, 1.000 Einträge und 250 Dialoge.",
+ "archiveCorrupt": "Das Archiv ist beschädigt oder enthält ungültige Pfade oder Datensätze. Verwenden Sie eine geprüfte Sicherung oder exportieren Sie es erneut.",
+ "archiveUnsupported": "Dieses Archivformat wird nicht unterstützt. Exportieren Sie ein Standardarchiv für Projekte oder Dialoge mit einer kompatiblen App-Version."
+ }
+ },
+ "metrics": {
+ "payloadEstimate": "Geschätzte Nutzdaten",
+ "unavailable": "Nicht verfügbar",
+ "currentUser": "Aktueller Benutzer"
+ },
+ "accessibility": {
+ "editorCommands": "Editorbefehle",
+ "editSelection": "Auswahl bearbeiten",
+ "dialogueGraph": "Dialoggraph",
+ "zoom": "Zoom",
+ "zoomIn": "Vergrößern",
+ "zoomOut": "Verkleinern",
+ "connectExisting": "Mit vorhandenem Knoten verbinden",
+ "chooseConnection": "Zielknoten auswählen",
+ "noConnections": "Keine verbindbaren Knoten verfügbar",
+ "chooseNode": "Knoten auswählen…",
+ "connect": "Verbinden",
+ "nodeType": "Knotentyp auswählen",
+ "nodeTypeDescription": "Wählen Sie den Knotentyp für den Dialog"
+ },
+ "archive": {
+ "importTitle": "Archiv importieren",
+ "copy": "Als neue Kopie importieren",
+ "replace": "Vorhandenes Element ersetzen",
+ "target": "Zu ersetzendes Element",
+ "replaceWarning": "Das ausgewählte Element und sein Graph werden ersetzt. Exportieren Sie vorher eine Sicherung, um die aktuelle Version zu behalten.",
+ "confirmReplace": "Ausgewähltes Element ersetzen",
+ "failed": "Import fehlgeschlagen. Prüfen Sie das Archiv und versuchen Sie es erneut."
+ },
+ "recovery": {
+ "title": "Projektwiederherstellung",
+ "description": "Korrigieren Sie diese Verweise vor dem Export oder der Synchronisierung. Originaldatensätze bleiben erhalten. Fehlende Texte oder Medien müssen aus einer Sicherung wiederhergestellt werden; die App kann sie nicht rekonstruieren.",
+ "failed": "Wiederherstellung fehlgeschlagen. Prüfen Sie die Details und versuchen Sie es erneut.",
+ "details": "Technische Details",
+ "revalidate": "Reparierte Datensätze erneut prüfen",
+ "record": "Datensatz",
+ "unknown": "unbekannt",
+ "download": "Originaldaten herunterladen",
+ "replacement": "Ersatz für {{id}} {{row}}",
+ "choose": "Ersatz auswählen",
+ "assign": "Zuweisen",
+ "restoreDescription": "Stellen Sie diesen Knoten mit einer eigenen Identität und dem vorgesehenen Typ (nicht Start) wieder her. Prüfen Sie vorher Dialog und Inhalte.",
+ "prepare": "Wiederhergestellten Knoten vorbereiten",
+ "nodeJson": "JSON des wiederhergestellten Knotens",
+ "restore": "Geprüften Knoten wiederherstellen",
+ "globalTitle": "Wiederherstellungsbericht",
+ "globalDescription": "Einige gespeicherte Datensätze müssen geprüft werden. Die Originaldaten bleiben erhalten.",
+ "orphanGuidance": "Für diese Datensätze ist kein zugehöriges Projekt verfügbar. Laden Sie die Originaldaten herunter und vergleichen Sie sie mit einer Sicherung, bevor Sie sie manuell in einem Projekt wiederherstellen. Die Zuordnung wurde nicht automatisch vorgenommen.",
+ "openProject": "Wiederherstellungseinstellungen des Projekts öffnen",
+ "reportDownload": "Wiederherstellungsbericht herunterladen",
+ "refresh": "Bericht aktualisieren"
+ },
+ "syncConflicts": {
+ "title": "Synchronisierungskonflikte",
+ "description": "Beide Versionen bleiben erhalten. Wählen Sie die Version, die Sie behalten möchten.",
+ "incomplete": "Ein Teil des Versionsverlaufs ist nicht verfügbar. Synchronisieren Sie erneut, bevor Sie den Konflikt lösen.",
+ "keepLocal": "Lokale Version behalten",
+ "keepRemote": "Remoteversion behalten",
+ "keepBoth": "Beide Projekte behalten",
+ "refresh": "Konflikte aktualisieren",
+ "selectRevision": "Konkurrierende Version",
+ "revision": "Version {{id}}",
+ "deleted": "Gelöschtes Projekt",
+ "resolveError": "Dieser Konflikt konnte nicht gelöst werden."
}
}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 4c0e442..fc1b894 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -34,7 +34,8 @@
"minutesAgo_plural": "{{count}}m ago",
"hoursAgo": "{{count}}h ago",
"hoursAgo_plural": "{{count}}h ago"
- }
+ },
+ "retry": "Retry"
},
"navigation": {
"dashboard": "Dashboard",
@@ -46,7 +47,7 @@
"settings": "Settings"
},
"dashboard": {
- "diskUsage": "Disk Usage"
+ "diskUsage": "Origin storage usage"
},
"decorators": {
"title": "Decorators",
@@ -189,9 +190,9 @@
"nodeToolbar": {
"autoLayout": "Auto Layout",
"autoLayoutTooltip": "Auto-layout nodes using dagre algorithm",
- "recenter": "Recenter",
- "recenterTooltip": "Fit the whole graph into view",
- "backToStart": "Back to Start",
+ "recenter": "Recenter graph",
+ "recenterTooltip": "Fit the graph in view",
+ "backToStart": "Focus Start node",
"startFocusTooltip": "Focus the Start node",
"npc": "NPC",
"addNpcTooltip": "Add NPC node - drag to canvas or click",
@@ -553,5 +554,104 @@
"required": "This field is required",
"invalidName": "Invalid name",
"nameTaken": "This name is already in use"
+ },
+ "errors": {
+ "unexpectedTitle": "Unexpected error",
+ "unexpectedDescription": "An unexpected error occurred. Restart the app and try again.",
+ "storageInitialization": "Local storage could not be opened safely. Your original database has been preserved.",
+ "actions": {
+ "load": "Could not load data",
+ "create": "Could not create item",
+ "update": "Could not update item",
+ "save": "Could not save changes",
+ "delete": "Could not delete item",
+ "import": "Could not import archive",
+ "export": "Could not export archive"
+ },
+ "details": {
+ "unexpected": "The operation failed. Try again; if it keeps failing, retain your backup and report the problem.",
+ "stale": "The profile or project changed. Reload and retry.",
+ "exists": "The destination already exists. Choose a new copy or explicit replacement.",
+ "missing": "The referenced item is unavailable. Check the selected project.",
+ "referenced": "Remove or reassign these references before changing or deleting this item.",
+ "categoryCycle": "A category cannot be its own ancestor. Choose another parent.",
+ "categoryDepth": "Categories support at most five levels. Choose a higher parent.",
+ "reference": "Choose an unambiguous item belonging to this project.",
+ "name": "Use 1–16 letters or numbers for the name.",
+ "duplicateName": "Choose a name that is unique within this category tree.",
+ "definition": "Check property names, types and default values; referenced property types must remain compatible.",
+ "repair": "Repair the project references and translations, then retry.",
+ "media": "Restore the original media bytes and verify the attachment before retrying.",
+ "duplicateIdentity": "Choose a distinct identity for the duplicate record.",
+ "quota": "Storage is full. Export a backup and free space before retrying.",
+ "archiveLimit": "Reduce the archive size or split the project: limits are 25 MiB compressed, 128 MiB expanded, 5 MiB per JSON entry, 1,000 entries and 250 dialogues.",
+ "archiveCorrupt": "The archive is damaged or contains invalid paths or records. Restore a verified backup or export it again.",
+ "archiveUnsupported": "This archive format is unsupported. Export a standard project or dialogue archive from a compatible app version."
+ }
+ },
+ "metrics": {
+ "payloadEstimate": "Payload estimate",
+ "unavailable": "Unavailable",
+ "currentUser": "Current user"
+ },
+ "accessibility": {
+ "editorCommands": "Editor commands",
+ "editSelection": "Edit selection",
+ "dialogueGraph": "Dialogue graph",
+ "zoom": "Zoom",
+ "zoomIn": "Zoom in",
+ "zoomOut": "Zoom out",
+ "connectExisting": "Connect to existing node",
+ "chooseConnection": "Select a node to connect to",
+ "noConnections": "No connectable nodes available",
+ "chooseNode": "Choose a node…",
+ "connect": "Connect",
+ "nodeType": "Select node type",
+ "nodeTypeDescription": "Choose the type of node to add to the dialogue"
+ },
+ "archive": {
+ "importTitle": "Import archive",
+ "copy": "Import as a new copy",
+ "replace": "Replace an existing item",
+ "target": "Item to replace",
+ "replaceWarning": "The selected item and its graph content will be replaced. Export a backup first to keep the current version.",
+ "confirmReplace": "Replace selected item",
+ "failed": "Import failed. Review the archive and try again."
+ },
+ "recovery": {
+ "title": "Project recovery",
+ "description": "Resolve these references before exporting or synchronizing. Original records remain preserved. Recover missing text or media from an existing backup; the app cannot reconstruct them.",
+ "failed": "Recovery failed. Review the details and try again.",
+ "details": "Technical details",
+ "revalidate": "Revalidate repaired records",
+ "record": "Record",
+ "unknown": "unknown",
+ "download": "Download original evidence",
+ "replacement": "Replacement for {{id}} {{row}}",
+ "choose": "Choose a replacement",
+ "assign": "Assign",
+ "restoreDescription": "Restore this original node with a distinct identity and its intended non-Start type. Review its dialogue and authored data before restoring.",
+ "prepare": "Prepare recovered node",
+ "nodeJson": "Recovered node JSON",
+ "restore": "Restore reviewed node",
+ "globalTitle": "Recovery report",
+ "globalDescription": "Some saved records need review. Original evidence is preserved.",
+ "orphanGuidance": "These records have no available owning project. Download the original evidence and compare it with a backup before manually recovering it into a project. No ownership has been assigned automatically.",
+ "openProject": "Open project recovery settings",
+ "reportDownload": "Download recovery report",
+ "refresh": "Refresh report"
+ },
+ "syncConflicts": {
+ "title": "Synchronization conflicts",
+ "description": "Both versions are preserved. Choose which version to keep.",
+ "incomplete": "Some revision history is unavailable. Synchronize again before resolving.",
+ "keepLocal": "Keep local",
+ "keepRemote": "Keep remote",
+ "keepBoth": "Keep both projects",
+ "refresh": "Refresh conflicts",
+ "selectRevision": "Competing version",
+ "revision": "Revision {{id}}",
+ "deleted": "Deleted project",
+ "resolveError": "Could not resolve this conflict."
}
}
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index e4a45b0..4772289 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -31,7 +31,8 @@
"minutesAgo_plural": "hace {{count}} min",
"hoursAgo": "hace {{count}} h",
"hoursAgo_plural": "hace {{count}} h"
- }
+ },
+ "retry": "Reintentar"
},
"navigation": {
"dashboard": "Panel",
@@ -43,7 +44,7 @@
"settings": "Configuración"
},
"dashboard": {
- "diskUsage": "Uso del Disco"
+ "diskUsage": "Almacenamiento usado por este sitio"
},
"decorators": {
"title": "Decoradores",
@@ -172,7 +173,11 @@
"addCompleteTooltip": "Añadir nodo Completar - arrastra al lienzo o haz clic",
"delay": "Retardo",
"addDelayTooltip": "Añadir nodo Retardo - arrastra al lienzo o haz clic",
- "poweredBy": "Impulsado por"
+ "poweredBy": "Impulsado por",
+ "recenter": "Centrar grafo",
+ "backToStart": "Centrar nodo Start",
+ "recenterTooltip": "Mostrar todo el grafo",
+ "startFocusTooltip": "Centrar nodo Start"
},
"nodes": {
"start": "Inicio",
@@ -369,9 +374,9 @@
"tour": {
"dashboard": {
"welcomeTitle": "¡Bienvenido a Mountea Dialoguer!",
- "welcomeText": "Hagamos un breve recorrido para ayudarte a empezar. Despu\u00e9s del tour, ver\u00e1s la configuraci\u00f3n de sincronizaci\u00f3n en la nube.",
+ "welcomeText": "Hagamos un breve recorrido para ayudarte a empezar. Después del tour, verás la configuración de sincronización en la nube.",
"exampleTitle": "Empieza con un proyecto de ejemplo",
- "exampleText": "Crea un proyecto de muestra listo para explorar con un di\u00e1logo ya incluido.",
+ "exampleText": "Crea un proyecto de muestra listo para explorar con un diálogo ya incluido.",
"exampleAction": "Crear proyecto de ejemplo",
"createTitle": "Crea tu primer proyecto",
"createText": "Haz clic aquí para crear un nuevo proyecto. Los proyectos ayudan a organizar tus diálogos.",
@@ -452,5 +457,104 @@
"required": "Este campo es obligatorio",
"invalidName": "Nombre inválido",
"nameTaken": "Este nombre ya está en uso"
+ },
+ "errors": {
+ "unexpectedTitle": "Error inesperado",
+ "unexpectedDescription": "Se produjo un error inesperado. Reinicia la aplicación e inténtalo de nuevo.",
+ "storageInitialization": "No se pudo abrir el almacenamiento local de forma segura. Se ha conservado la base de datos original.",
+ "actions": {
+ "load": "No se pudieron cargar los datos",
+ "create": "No se pudo crear el elemento",
+ "update": "No se pudo actualizar el elemento",
+ "save": "No se pudieron guardar los cambios",
+ "delete": "No se pudo eliminar el elemento",
+ "import": "No se pudo importar el archivo",
+ "export": "No se pudo exportar el archivo"
+ },
+ "details": {
+ "unexpected": "La operación falló. Inténtelo de nuevo; si persiste, conserve la copia de seguridad e informe del problema.",
+ "stale": "El perfil o el proyecto ha cambiado. Recargue los datos e inténtelo de nuevo.",
+ "exists": "El destino ya existe. Elija una nueva copia o un reemplazo explícito.",
+ "missing": "El elemento referenciado no está disponible. Compruebe el proyecto seleccionado.",
+ "referenced": "Elimine o reasigne estas referencias antes de cambiar o eliminar este elemento.",
+ "categoryCycle": "Una categoría no puede ser su propio antecesor. Elija otro padre.",
+ "categoryDepth": "Las categorías admiten un máximo de cinco niveles. Elija un padre superior.",
+ "reference": "Elija un elemento inequívoco que pertenezca a este proyecto.",
+ "name": "Use de 1 a 16 letras o números para el nombre.",
+ "duplicateName": "Elija un nombre único dentro de este árbol de categorías.",
+ "definition": "Compruebe nombres, tipos y valores predeterminados de las propiedades; los tipos utilizados deben seguir siendo compatibles.",
+ "repair": "Repare las referencias y traducciones del proyecto y vuelva a intentarlo.",
+ "media": "Restaure los datos originales del medio y compruebe el adjunto.",
+ "duplicateIdentity": "Elija una identidad distinta para el registro duplicado.",
+ "quota": "El almacenamiento está lleno. Exporte una copia de seguridad y libere espacio.",
+ "archiveLimit": "Reduzca el archivo o divida el proyecto: límites de 25 MiB comprimidos, 128 MiB descomprimidos, 5 MiB por entrada JSON, 1.000 entradas y 250 diálogos.",
+ "archiveCorrupt": "El archivo está dañado o contiene rutas o registros no válidos. Restaure una copia de seguridad verificada o expórtelo de nuevo.",
+ "archiveUnsupported": "Este formato de archivo no es compatible. Exporte un archivo estándar de proyecto o diálogo desde una versión compatible de la aplicación."
+ }
+ },
+ "metrics": {
+ "payloadEstimate": "Estimación de datos",
+ "unavailable": "No disponible",
+ "currentUser": "Usuario actual"
+ },
+ "accessibility": {
+ "editorCommands": "Comandos del editor",
+ "editSelection": "Editar selección",
+ "dialogueGraph": "Grafo de diálogo",
+ "zoom": "Zoom",
+ "zoomIn": "Acercar",
+ "zoomOut": "Alejar",
+ "connectExisting": "Conectar a un nodo existente",
+ "chooseConnection": "Selecciona un nodo para conectar",
+ "noConnections": "No hay nodos disponibles para conectar",
+ "chooseNode": "Elige un nodo…",
+ "connect": "Conectar",
+ "nodeType": "Selecciona el tipo de nodo",
+ "nodeTypeDescription": "Elige el tipo de nodo que quieres añadir al diálogo"
+ },
+ "archive": {
+ "importTitle": "Importar archivo",
+ "copy": "Importar como copia nueva",
+ "replace": "Reemplazar un elemento existente",
+ "target": "Elemento que reemplazar",
+ "replaceWarning": "Se reemplazarán el elemento seleccionado y su grafo. Exporta primero una copia de seguridad para conservar la versión actual.",
+ "confirmReplace": "Reemplazar elemento seleccionado",
+ "failed": "La importación falló. Revisa el archivo e inténtalo de nuevo."
+ },
+ "recovery": {
+ "title": "Recuperación del proyecto",
+ "description": "Resuelve estas referencias antes de exportar o sincronizar. Se conservan los registros originales. Recupera los textos o medios que falten desde una copia de seguridad; la aplicación no puede reconstruirlos.",
+ "failed": "La recuperación falló. Revisa los detalles e inténtalo de nuevo.",
+ "details": "Detalles técnicos",
+ "revalidate": "Volver a validar los registros reparados",
+ "record": "Registro",
+ "unknown": "desconocido",
+ "download": "Descargar datos originales",
+ "replacement": "Reemplazo para {{id}} {{row}}",
+ "choose": "Elegir reemplazo",
+ "assign": "Asignar",
+ "restoreDescription": "Restaura este nodo con una identidad distinta y su tipo previsto, diferente de Start. Revisa su diálogo y contenido antes de restaurarlo.",
+ "prepare": "Preparar nodo recuperado",
+ "nodeJson": "JSON del nodo recuperado",
+ "restore": "Restaurar nodo revisado",
+ "globalTitle": "Informe de recuperación",
+ "globalDescription": "Algunos registros guardados requieren revisión. Los datos originales se conservan.",
+ "orphanGuidance": "Estos registros no tienen un proyecto propietario disponible. Descargue los datos originales y compárelos con una copia de seguridad antes de recuperarlos manualmente en un proyecto. No se ha asignado ningún propietario automáticamente.",
+ "openProject": "Abrir los ajustes de recuperación del proyecto",
+ "reportDownload": "Descargar informe de recuperación",
+ "refresh": "Actualizar informe"
+ },
+ "syncConflicts": {
+ "title": "Conflictos de sincronización",
+ "description": "Se conservan ambas versiones. Elige cuál quieres mantener.",
+ "incomplete": "Parte del historial no está disponible. Vuelve a sincronizar antes de resolver el conflicto.",
+ "keepLocal": "Mantener versión local",
+ "keepRemote": "Mantener versión remota",
+ "keepBoth": "Mantener ambos proyectos",
+ "refresh": "Actualizar conflictos",
+ "selectRevision": "Versión en conflicto",
+ "revision": "Revisión {{id}}",
+ "deleted": "Proyecto eliminado",
+ "resolveError": "No se pudo resolver este conflicto."
}
}
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index 451af56..13a4076 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -31,7 +31,8 @@
"minutesAgo_plural": "il y a {{count}} min",
"hoursAgo": "il y a {{count}} h",
"hoursAgo_plural": "il y a {{count}} h"
- }
+ },
+ "retry": "Réessayer"
},
"navigation": {
"dashboard": "Tableau de bord",
@@ -43,7 +44,7 @@
"settings": "Paramètres"
},
"dashboard": {
- "diskUsage": "Utilisation du Disque"
+ "diskUsage": "Stockage utilisé par ce site"
},
"decorators": {
"title": "Décorateurs",
@@ -172,7 +173,11 @@
"addCompleteTooltip": "Ajouter un nœud Terminer - glisser sur le canevas ou cliquer",
"delay": "Délai",
"addDelayTooltip": "Ajouter un nœud Délai - glisser sur le canevas ou cliquer",
- "poweredBy": "Propulsé par"
+ "poweredBy": "Propulsé par",
+ "recenter": "Recentrer le graphe",
+ "backToStart": "Centrer le nœud Start",
+ "recenterTooltip": "Afficher tout le graphe",
+ "startFocusTooltip": "Centrer le nœud Start"
},
"nodes": {
"start": "Début",
@@ -452,5 +457,104 @@
"required": "Ce champ est requis",
"invalidName": "Nom invalide",
"nameTaken": "Ce nom est déjà utilisé"
+ },
+ "errors": {
+ "unexpectedTitle": "Erreur inattendue",
+ "unexpectedDescription": "Une erreur inattendue est survenue. Redémarrez l’application et réessayez.",
+ "storageInitialization": "Le stockage local n’a pas pu être ouvert en toute sécurité. Votre base de données d’origine a été conservée.",
+ "actions": {
+ "load": "Impossible de charger les données",
+ "create": "Impossible de créer cet élément",
+ "update": "Impossible de mettre à jour cet élément",
+ "save": "Impossible d’enregistrer les modifications",
+ "delete": "Impossible de supprimer cet élément",
+ "import": "Impossible d’importer l’archive",
+ "export": "Impossible d’exporter l’archive"
+ },
+ "details": {
+ "unexpected": "L’opération a échoué. Réessayez ; si le problème persiste, conservez votre sauvegarde et signalez-le.",
+ "stale": "Le profil ou le projet a changé. Rechargez les données et réessayez.",
+ "exists": "La destination existe déjà. Choisissez une nouvelle copie ou un remplacement explicite.",
+ "missing": "L’élément référencé est indisponible. Vérifiez le projet sélectionné.",
+ "referenced": "Supprimez ou réaffectez ces références avant de modifier ou supprimer cet élément.",
+ "categoryCycle": "Une catégorie ne peut pas être son propre ancêtre. Choisissez un autre parent.",
+ "categoryDepth": "Les catégories acceptent au maximum cinq niveaux. Choisissez un parent plus haut.",
+ "reference": "Choisissez un élément non ambigu appartenant à ce projet.",
+ "name": "Utilisez 1 à 16 lettres ou chiffres pour le nom.",
+ "duplicateName": "Choisissez un nom unique dans cet arbre de catégories.",
+ "definition": "Vérifiez les noms, types et valeurs par défaut des propriétés ; les types utilisés doivent rester compatibles.",
+ "repair": "Réparez les références et traductions du projet, puis réessayez.",
+ "media": "Restaurez les données originales du média et vérifiez la pièce jointe.",
+ "duplicateIdentity": "Choisissez une identité différente pour l’enregistrement en double.",
+ "quota": "Le stockage est plein. Exportez une sauvegarde et libérez de l’espace.",
+ "archiveLimit": "Réduisez l’archive ou divisez le projet : limites de 25 Mio compressés, 128 Mio décompressés, 5 Mio par entrée JSON, 1 000 entrées et 250 dialogues.",
+ "archiveCorrupt": "L’archive est endommagée ou contient des chemins ou enregistrements invalides. Restaurez une sauvegarde vérifiée ou exportez-la à nouveau.",
+ "archiveUnsupported": "Ce format d’archive n’est pas pris en charge. Exportez une archive standard de projet ou de dialogue avec une version compatible de l’application."
+ }
+ },
+ "metrics": {
+ "payloadEstimate": "Estimation des données",
+ "unavailable": "Indisponible",
+ "currentUser": "Utilisateur actuel"
+ },
+ "accessibility": {
+ "editorCommands": "Commandes de l’éditeur",
+ "editSelection": "Modifier la sélection",
+ "dialogueGraph": "Graphe de dialogue",
+ "zoom": "Zoom",
+ "zoomIn": "Agrandir",
+ "zoomOut": "Réduire",
+ "connectExisting": "Connecter à un nœud existant",
+ "chooseConnection": "Sélectionner le nœud à connecter",
+ "noConnections": "Aucun nœud à connecter",
+ "chooseNode": "Choisir un nœud…",
+ "connect": "Connecter",
+ "nodeType": "Choisir le type de nœud",
+ "nodeTypeDescription": "Choisissez le type de nœud à ajouter au dialogue"
+ },
+ "archive": {
+ "importTitle": "Importer une archive",
+ "copy": "Importer une nouvelle copie",
+ "replace": "Remplacer un élément existant",
+ "target": "Élément à remplacer",
+ "replaceWarning": "L’élément sélectionné et son graphe seront remplacés. Exportez d’abord une sauvegarde pour conserver la version actuelle.",
+ "confirmReplace": "Remplacer l’élément sélectionné",
+ "failed": "L’importation a échoué. Vérifiez l’archive et réessayez."
+ },
+ "recovery": {
+ "title": "Récupération du projet",
+ "description": "Corrigez ces références avant l’exportation ou la synchronisation. Les données d’origine sont conservées. Récupérez les textes ou médias manquants depuis une sauvegarde ; l’application ne peut pas les reconstruire.",
+ "failed": "La récupération a échoué. Vérifiez les détails et réessayez.",
+ "details": "Détails techniques",
+ "revalidate": "Revérifier les données réparées",
+ "record": "Enregistrement",
+ "unknown": "inconnu",
+ "download": "Télécharger les données d’origine",
+ "replacement": "Remplacement pour {{id}} {{row}}",
+ "choose": "Choisir un remplacement",
+ "assign": "Attribuer",
+ "restoreDescription": "Restaurez ce nœud avec une identité distincte et son type prévu, autre que Start. Vérifiez son dialogue et son contenu avant la restauration.",
+ "prepare": "Préparer le nœud récupéré",
+ "nodeJson": "JSON du nœud récupéré",
+ "restore": "Restaurer le nœud vérifié",
+ "globalTitle": "Rapport de récupération",
+ "globalDescription": "Certains enregistrements doivent être examinés. Les données originales sont conservées.",
+ "orphanGuidance": "Aucun projet propriétaire de ces enregistrements n’est disponible. Téléchargez les données originales et comparez-les à une sauvegarde avant de les récupérer manuellement dans un projet. Aucune attribution automatique n’a été effectuée.",
+ "openProject": "Ouvrir les paramètres de récupération du projet",
+ "reportDownload": "Télécharger le rapport de récupération",
+ "refresh": "Actualiser le rapport"
+ },
+ "syncConflicts": {
+ "title": "Conflits de synchronisation",
+ "description": "Les deux versions sont conservées. Choisissez celle à garder.",
+ "incomplete": "Une partie de l’historique est indisponible. Relancez la synchronisation avant de résoudre le conflit.",
+ "keepLocal": "Garder la version locale",
+ "keepRemote": "Garder la version distante",
+ "keepBoth": "Garder les deux projets",
+ "refresh": "Actualiser les conflits",
+ "selectRevision": "Version concurrente",
+ "revision": "Révision {{id}}",
+ "deleted": "Projet supprimé",
+ "resolveError": "Impossible de résoudre ce conflit."
}
}
diff --git a/src/i18n/locales/pl.json b/src/i18n/locales/pl.json
index 8e0029d..2979809 100644
--- a/src/i18n/locales/pl.json
+++ b/src/i18n/locales/pl.json
@@ -34,7 +34,8 @@
"minutesAgo_plural": "{{count}} min temu",
"hoursAgo": "{{count}} godz. temu",
"hoursAgo_plural": "{{count}} godz. temu"
- }
+ },
+ "retry": "Spróbuj ponownie"
},
"navigation": {
"dashboard": "Pulpit",
@@ -46,7 +47,7 @@
"settings": "Ustawienia"
},
"dashboard": {
- "diskUsage": "Zużycie dysku"
+ "diskUsage": "Pamięć używana przez tę witrynę"
},
"decorators": {
"title": "Dekoratory",
@@ -174,7 +175,11 @@
"addCompleteTooltip": "Dodaj węzeł Zakończ — przeciągnij na płótno lub kliknij",
"delay": "Opóźnienie",
"addDelayTooltip": "Dodaj węzeł Opóźnienie — przeciągnij na płótno lub kliknij",
- "poweredBy": "Zasilane przez"
+ "poweredBy": "Zasilane przez",
+ "recenter": "Wyśrodkuj graf",
+ "backToStart": "Przejdź do węzła Start",
+ "recenterTooltip": "Pokaż cały graf",
+ "startFocusTooltip": "Przejdź do węzła Start"
},
"nodes": {
"start": "Start",
@@ -484,5 +489,104 @@
"required": "To pole jest wymagane",
"invalidName": "Nieprawidłowa nazwa",
"nameTaken": "Ta nazwa jest już używana"
+ },
+ "errors": {
+ "unexpectedTitle": "Nieoczekiwany błąd",
+ "unexpectedDescription": "Wystąpił nieoczekiwany błąd. Uruchom aplikację ponownie i spróbuj jeszcze raz.",
+ "storageInitialization": "Nie udało się bezpiecznie otworzyć pamięci lokalnej. Oryginalna baza danych została zachowana.",
+ "actions": {
+ "load": "Nie można wczytać danych",
+ "create": "Nie można utworzyć elementu",
+ "update": "Nie można zaktualizować elementu",
+ "save": "Nie można zapisać zmian",
+ "delete": "Nie można usunąć elementu",
+ "import": "Nie można zaimportować archiwum",
+ "export": "Nie można wyeksportować archiwum"
+ },
+ "details": {
+ "unexpected": "Operacja nie powiodła się. Spróbuj ponownie; jeśli problem się powtarza, zachowaj kopię zapasową i zgłoś go.",
+ "stale": "Profil lub projekt uległ zmianie. Wczytaj dane ponownie i ponów próbę.",
+ "exists": "Cel już istnieje. Wybierz nową kopię lub jawne zastąpienie.",
+ "missing": "Wskazany element jest niedostępny. Sprawdź wybrany projekt.",
+ "referenced": "Usuń lub przypisz ponownie te odwołania przed zmianą albo usunięciem elementu.",
+ "categoryCycle": "Kategoria nie może być własnym przodkiem. Wybierz innego rodzica.",
+ "categoryDepth": "Kategorie obsługują najwyżej pięć poziomów. Wybierz wyższego rodzica.",
+ "reference": "Wybierz jednoznaczny element należący do tego projektu.",
+ "name": "Nazwa musi zawierać od 1 do 16 liter lub cyfr.",
+ "duplicateName": "Wybierz nazwę unikalną w tym drzewie kategorii.",
+ "definition": "Sprawdź nazwy, typy i wartości domyślne właściwości; używane typy muszą pozostać zgodne.",
+ "repair": "Napraw odwołania i tłumaczenia projektu, a następnie ponów próbę.",
+ "media": "Przywróć oryginalne dane multimedialne i sprawdź załącznik.",
+ "duplicateIdentity": "Wybierz inną tożsamość dla zduplikowanego rekordu.",
+ "quota": "Pamięć jest pełna. Wyeksportuj kopię zapasową i zwolnij miejsce.",
+ "archiveLimit": "Zmniejsz archiwum lub podziel projekt: limity to 25 MiB po kompresji, 128 MiB po rozpakowaniu, 5 MiB na wpis JSON, 1000 wpisów i 250 dialogów.",
+ "archiveCorrupt": "Archiwum jest uszkodzone lub zawiera nieprawidłowe ścieżki albo rekordy. Przywróć sprawdzoną kopię zapasową lub ponownie wyeksportuj dane.",
+ "archiveUnsupported": "Ten format archiwum nie jest obsługiwany. Wyeksportuj standardowe archiwum projektu lub dialogu ze zgodnej wersji aplikacji."
+ }
+ },
+ "metrics": {
+ "payloadEstimate": "Szacowany rozmiar danych",
+ "unavailable": "Niedostępne",
+ "currentUser": "Bieżący użytkownik"
+ },
+ "accessibility": {
+ "editorCommands": "Polecenia edytora",
+ "editSelection": "Edytuj zaznaczenie",
+ "dialogueGraph": "Graf dialogu",
+ "zoom": "Powiększenie",
+ "zoomIn": "Powiększ",
+ "zoomOut": "Pomniejsz",
+ "connectExisting": "Połącz z istniejącym węzłem",
+ "chooseConnection": "Wybierz węzeł do połączenia",
+ "noConnections": "Brak węzłów do połączenia",
+ "chooseNode": "Wybierz węzeł…",
+ "connect": "Połącz",
+ "nodeType": "Wybierz typ węzła",
+ "nodeTypeDescription": "Wybierz typ węzła do dodania do dialogu"
+ },
+ "archive": {
+ "importTitle": "Importuj archiwum",
+ "copy": "Importuj jako nową kopię",
+ "replace": "Zastąp istniejący element",
+ "target": "Element do zastąpienia",
+ "replaceWarning": "Wybrany element i jego graf zostaną zastąpione. Najpierw wyeksportuj kopię zapasową, aby zachować bieżącą wersję.",
+ "confirmReplace": "Zastąp wybrany element",
+ "failed": "Import nie powiódł się. Sprawdź archiwum i spróbuj ponownie."
+ },
+ "recovery": {
+ "title": "Odzyskiwanie projektu",
+ "description": "Popraw te odwołania przed eksportem lub synchronizacją. Oryginalne rekordy zostają zachowane. Brakujące teksty lub multimedia odzyskaj z kopii zapasowej; aplikacja nie może ich odtworzyć.",
+ "failed": "Odzyskiwanie nie powiodło się. Sprawdź szczegóły i spróbuj ponownie.",
+ "details": "Szczegóły techniczne",
+ "revalidate": "Ponownie sprawdź naprawione rekordy",
+ "record": "Rekord",
+ "unknown": "nieznany",
+ "download": "Pobierz oryginalne dane",
+ "replacement": "Zamiennik dla {{id}} {{row}}",
+ "choose": "Wybierz zamiennik",
+ "assign": "Przypisz",
+ "restoreDescription": "Przywróć ten węzeł z odrębnym identyfikatorem i zamierzonym typem innym niż Start. Przed przywróceniem sprawdź dialog i jego zawartość.",
+ "prepare": "Przygotuj odzyskany węzeł",
+ "nodeJson": "JSON odzyskanego węzła",
+ "restore": "Przywróć sprawdzony węzeł",
+ "globalTitle": "Raport odzyskiwania",
+ "globalDescription": "Niektóre zapisane rekordy wymagają sprawdzenia. Oryginalne dane zostały zachowane.",
+ "orphanGuidance": "Te rekordy nie mają dostępnego projektu nadrzędnego. Pobierz oryginalne dane i porównaj je z kopią zapasową przed ręcznym odzyskaniem w projekcie. Przynależność nie została przypisana automatycznie.",
+ "openProject": "Otwórz ustawienia odzyskiwania projektu",
+ "reportDownload": "Pobierz raport odzyskiwania",
+ "refresh": "Odśwież raport"
+ },
+ "syncConflicts": {
+ "title": "Konflikty synchronizacji",
+ "description": "Obie wersje zostają zachowane. Wybierz wersję do zachowania.",
+ "incomplete": "Część historii wersji jest niedostępna. Zsynchronizuj ponownie przed rozwiązaniem konfliktu.",
+ "keepLocal": "Zachowaj lokalną",
+ "keepRemote": "Zachowaj zdalną",
+ "keepBoth": "Zachowaj oba projekty",
+ "refresh": "Odśwież konflikty",
+ "selectRevision": "Konfliktowa wersja",
+ "revision": "Wersja {{id}}",
+ "deleted": "Usunięty projekt",
+ "resolveError": "Nie udało się rozwiązać tego konfliktu."
}
}
diff --git a/src/lib/achievements/achievementTracker.js b/src/lib/achievements/achievementTracker.js
index 10805af..e817923 100644
--- a/src/lib/achievements/achievementTracker.js
+++ b/src/lib/achievements/achievementTracker.js
@@ -3,6 +3,8 @@ import { useSteamStore } from '@/stores/steamStore';
import { STEAM_ACHIEVEMENT_IDS } from '@/config/steamAchievements';
import {
readProfileScopedItem,
+ getActiveProfileId, getProfileGeneration,
+ buildProfileScopedKey, subscribeProfileChanges,
writeProfileScopedItem,
} from '@/lib/profile/activeProfile';
@@ -33,29 +35,49 @@ function saveAchievementState(nextState) {
writeLocalStorage(ACHIEVEMENT_STATE_KEY, JSON.stringify(nextState || {}));
}
-function isAchievementUnlocked(achievementId) {
- const state = loadAchievementState();
- return Boolean(state[achievementId]);
+const pendingRetries = new Map();
+
+export async function retryPendingAchievements() {
+ const profile = getActiveProfileId(), generation = getProfileGeneration();
+ const status = useSteamStore.getState().status;
+ if (!status?.available || (status.steamId && profile !== `steam-${status.steamId}`)) return false;
+ if (pendingRetries.has(profile)) return pendingRetries.get(profile);
+ const eligible = ([id, entry]) => entry && !entry.acknowledgedAt && Object.values(STEAM_ACHIEVEMENT_IDS).includes(id);
+ if (!Object.entries(loadAchievementState()).some(eligible)) return false;
+ const task = (async () => {
+ const attempted = new Set();
+ while (profile === getActiveProfileId() && generation === getProfileGeneration()) {
+ const next = Object.entries(loadAchievementState()).find((entry) => eligible(entry) && !attempted.has(entry[0]));
+ if (!next) break;
+ const [id, entry] = next;
+ attempted.add(id);
+ try {
+ const result = await useSteamStore.getState().unlockAchievement(id);
+ if (profile !== getActiveProfileId() || generation !== getProfileGeneration()) break;
+ if (result?.ok) {
+ const state = loadAchievementState();
+ state[id] = { ...entry, status: 'acknowledged', acknowledgedAt: new Date().toISOString() };
+ saveAchievementState(state);
+ }
+ } catch { /* Earned state remains pending for the next availability/timer retry. */ }
+ }
+ return true;
+ })();
+ pendingRetries.set(profile, task);
+ try { return await task; } finally { pendingRetries.delete(profile); }
}
async function unlockAchievement(achievementId) {
- const id = String(achievementId || '').trim();
- if (!id) return false;
- if (isAchievementUnlocked(id)) return false;
-
- const state = loadAchievementState();
- state[id] = {
- unlockedAt: new Date().toISOString(),
- };
- saveAchievementState(state);
-
- try {
- await useSteamStore.getState().unlockAchievement(id);
- } catch (error) {
- // Keep local unlock state even when Steam runtime is unavailable.
- }
-
- return true;
+ const id = String(achievementId || '').trim();
+ if (!id) return false;
+ const state = loadAchievementState();
+ const newlyEarned = !state[id];
+ if (newlyEarned) {
+ state[id] = { earnedAt: new Date().toISOString(), status: 'pending' };
+ saveAchievementState(state);
+ }
+ await retryPendingAchievements();
+ return newlyEarned;
}
function isExampleProject(project) {
@@ -118,34 +140,51 @@ export async function trackFirstConditionCreated(projectId) {
);
}
-export function markUserActivity() {
- writeLocalStorage(LAST_ACTIVITY_TS_KEY, String(Date.now()));
+const ACTIVITY_STATE_KEY = 'mountea-activity-v2';
+const activityByProfile = new Map();
+function activityState() {
+ const profile = getActiveProfileId();
+ if (!activityByProfile.has(profile)) {
+ let saved = {};
+ try { saved = JSON.parse(readLocalStorage(ACTIVITY_STATE_KEY, '{}')); } catch { /* Recover invalid preferences. */ }
+ const minutes = Number(saved?.minutes ?? readLocalStorage(PLAYTIME_MINUTES_KEY, '0'));
+ const lastActivity = Number(saved?.lastActivity ?? readLocalStorage(LAST_ACTIVITY_TS_KEY, '0'));
+ activityByProfile.set(profile, { minutes: Number.isFinite(minutes) ? Math.max(0, Math.floor(minutes)) : 0, lastActivity: Number.isFinite(lastActivity) ? lastActivity : 0, lastTick: Date.now(), lastFlush: 0, dirty: false });
+ }
+ return activityByProfile.get(profile);
}
-export function getTrackedPlaytimeMinutes() {
- const raw = readLocalStorage(PLAYTIME_MINUTES_KEY, '0');
- const value = Number(raw);
- if (!Number.isFinite(value) || value < 0) return 0;
- return Math.floor(value);
+export function markUserActivity() {
+ const state = activityState();
+ state.lastActivity = Date.now();
+ state.dirty = true;
}
-function setTrackedPlaytimeMinutes(minutes) {
- writeLocalStorage(PLAYTIME_MINUTES_KEY, String(minutes));
+export function flushActivity({ force = false, profile = getActiveProfileId() } = {}) {
+ const state = activityByProfile.get(profile), now = Date.now();
+ if (!state) return false;
+ if (!state.dirty || (!force && now - state.lastFlush < 60_000)) return false;
+ window.localStorage.setItem(buildProfileScopedKey(ACTIVITY_STATE_KEY, profile), JSON.stringify({ minutes: state.minutes, lastActivity: state.lastActivity }));
+ state.lastFlush = now;
+ state.dirty = false;
+ return true;
}
-export async function trackActiveMinute() {
- const lastActivityRaw = readLocalStorage(LAST_ACTIVITY_TS_KEY, '0');
- const lastActivityTs = Number(lastActivityRaw);
- if (!Number.isFinite(lastActivityTs)) return false;
- if (Date.now() - lastActivityTs > ACTIVE_WINDOW_MS) return false;
- if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return false;
-
- const minutes = getTrackedPlaytimeMinutes() + 1;
- setTrackedPlaytimeMinutes(minutes);
+subscribeProfileChanges(() => {
+ for (const profile of activityByProfile.keys()) flushActivity({ force: true, profile });
+ void retryPendingAchievements();
+});
- if (minutes >= 600) {
- return await unlockAchievement(STEAM_ACHIEVEMENT_IDS.POWER_USER_10H);
- }
+export function getTrackedPlaytimeMinutes() { return activityState().minutes; }
- return false;
+export async function trackActiveMinute() {
+ const state = activityState(), now = Date.now();
+ if (now - state.lastTick < 60_000) return false;
+ state.lastTick = now;
+ const eligible = now - state.lastActivity <= ACTIVE_WINDOW_MS && (typeof document === 'undefined' || document.visibilityState === 'visible');
+ if (eligible) { state.minutes += 1; state.dirty = true; }
+ flushActivity();
+ await retryPendingAchievements();
+ if (eligible && state.minutes >= 600) return await unlockAchievement(STEAM_ACHIEVEMENT_IDS.POWER_USER_10H);
+ return false;
}
diff --git a/src/lib/assetNaming.js b/src/lib/assetNaming.js
index 0ff382b..edc9ec0 100644
--- a/src/lib/assetNaming.js
+++ b/src/lib/assetNaming.js
@@ -23,7 +23,7 @@ export function sanitizeAudioFileName(fileName, fallbackBase = 'Audio') {
const extRaw = hasExt ? raw.slice(lastDot + 1) : '';
const safeBase = sanitizeUnrealIdentifier(base, fallbackBase);
- const safeExt = sanitizeUnrealIdentifier(extRaw, '').toLowerCase();
+ const safeExt = hasExt ? toAscii(extRaw).replace(/[^A-Za-z0-9]/g, '').toLowerCase() : '';
return safeExt ? `${safeBase}.${safeExt}` : safeBase;
}
diff --git a/src/lib/assetNaming.test.js b/src/lib/assetNaming.test.js
deleted file mode 100644
index 36c511f..0000000
--- a/src/lib/assetNaming.test.js
+++ /dev/null
@@ -1,36 +0,0 @@
-/* eslint-env jest */
-import { buildParticipantImageId } from '@/lib/participantThumbnails';
-import {
- sanitizeAttachmentIdSegment,
- sanitizeAudioFileName,
- sanitizeUnrealIdentifier,
-} from '@/lib/assetNaming';
-
-describe('assetNaming', () => {
- test('sanitizes identifiers to Unreal-safe tokens', () => {
- expect(sanitizeUnrealIdentifier(' Ask for Work ')).toBe('Ask_for_Work');
- expect(sanitizeUnrealIdentifier('český název')).toBe('cesky_nazev');
- expect(sanitizeUnrealIdentifier('***')).toBe('Asset');
- });
-
- test('sanitizes attachment segments with fallback', () => {
- expect(sanitizeAttachmentIdSegment('A/B>C D')).toBe('A_B_C_D');
- expect(sanitizeAttachmentIdSegment('', 'Unknown')).toBe('Unknown');
- });
-
- test('sanitizes audio file names while preserving extension', () => {
- expect(sanitizeAudioFileName('Ask for Work.wav')).toBe('Ask_for_Work.wav');
- expect(sanitizeAudioFileName('Můj zvuk.MP3')).toBe('Muj_zvuk.mp3');
- expect(sanitizeAudioFileName(' ???.wav ')).toBe('Audio.wav');
- expect(sanitizeAudioFileName('name with no ext')).toBe('name_with_no_ext');
- });
-
- test('buildParticipantImageId uses sanitized category and name', () => {
- expect(
- buildParticipantImageId({
- participantName: 'Žoldák Hero',
- categoryPath: 'Main/Characters > Tier 1',
- })
- ).toBe('T_Main_Characters_Tier_1_Zoldak_Hero_Thumbnail');
- });
-});
diff --git a/src/lib/audioUtils.js b/src/lib/audioUtils.js
index c6b80e6..75b007c 100644
--- a/src/lib/audioUtils.js
+++ b/src/lib/audioUtils.js
@@ -55,6 +55,5 @@ export function restoreAudioFromStorage(storedAudio) {
name: sanitizeAudioFileName(storedAudio.name, 'Audio'),
size: storedAudio.size || blob.size,
blob: blob,
- url: URL.createObjectURL(blob),
};
}
diff --git a/src/lib/db.js b/src/lib/db.js
index b1a031c..e91895e 100644
--- a/src/lib/db.js
+++ b/src/lib/db.js
@@ -1,234 +1,139 @@
import Dexie from 'dexie';
-import { getActiveProfileId } from '@/lib/profile/activeProfile';
+import { getActiveProfileId, getProfileGeneration, subscribeProfileChanges } from '@/lib/profile/activeProfile';
+import { DATABASE_SCHEMA, AUTHORING_TABLES } from '@/lib/persistence/schema';
+import { migrateLegacyDatabase } from '@/lib/persistence/migration';
+import { transformLegacyRecords } from '@/lib/persistence/migration';
-/**
- * Mountea Dialoguer IndexedDB Database
- * Manages local storage for projects, dialogues, participants, and categories
- */
export class MounteaDialoguerDB extends Dexie {
- constructor(databaseName = 'MounteaDialoguerDB') {
- super(databaseName);
-
- // Version 1 - Auto-increment IDs (deprecated)
- this.version(1).stores({
- projects: '++id, name, createdAt, modifiedAt',
- dialogues: '++id, projectId, name, createdAt, modifiedAt',
- participants: '++id, projectId, name, category',
- categories: '++id, projectId, name',
- decorators: '++id, projectId, name, type',
- nodes: '++id, dialogueId, type, position',
- edges: '++id, dialogueId, source, target',
- });
-
- // Version 2 - UUID-based IDs
- this.version(2).stores({
- projects: 'id, name, createdAt, modifiedAt',
- dialogues: 'id, projectId, name, createdAt, modifiedAt',
- participants: 'id, projectId, name, category',
- categories: 'id, projectId, name',
- decorators: 'id, projectId, name, type',
- nodes: 'id, dialogueId, type, position',
- edges: 'id, dialogueId, source, target',
- });
-
- // Version 3 - Add parent category support (hierarchical categories)
- this.version(3).stores({
- projects: 'id, name, createdAt, modifiedAt',
- dialogues: 'id, projectId, name, createdAt, modifiedAt',
- participants: 'id, projectId, name, category',
- categories: 'id, projectId, name, parentCategoryId',
- decorators: 'id, projectId, name, type',
- nodes: 'id, dialogueId, type, position',
- edges: 'id, dialogueId, source, target',
- });
-
- // Version 4 - Use compound keys for nodes and edges (scoped by dialogue)
- this.version(4).stores({
- projects: 'id, name, createdAt, modifiedAt',
- dialogues: 'id, projectId, name, createdAt, modifiedAt',
- participants: 'id, projectId, name, category',
- categories: 'id, projectId, name, parentCategoryId',
- decorators: 'id, projectId, name, type',
- nodes: '[dialogueId+id], dialogueId, type',
- edges: '[dialogueId+id], dialogueId, source, target',
- }).upgrade(async (trans) => {
- // Migrate existing nodes and edges to use compound keys
- // Dexie will automatically handle the re-indexing with the new compound key
- // We just need to ensure the data has both dialogueId and id fields
- const nodes = await trans.table('nodes').toArray();
- const edges = await trans.table('edges').toArray();
-
- // Clear and re-add with compound keys
- await trans.table('nodes').clear();
- await trans.table('edges').clear();
-
- // Re-add nodes (they should already have dialogueId and id)
- if (nodes.length > 0) {
- await trans.table('nodes').bulkAdd(nodes);
- }
-
- // Re-add edges (they should already have dialogueId and id)
- if (edges.length > 0) {
- await trans.table('edges').bulkAdd(edges);
- }
- });
-
- // Version 5 - Add sync metadata tables
- this.version(5).stores({
- projects: 'id, name, createdAt, modifiedAt',
- dialogues: 'id, projectId, name, createdAt, modifiedAt',
- participants: 'id, projectId, name, category',
- categories: 'id, projectId, name, parentCategoryId',
- decorators: 'id, projectId, name, type',
- nodes: '[dialogueId+id], dialogueId, type',
- edges: '[dialogueId+id], dialogueId, source, target',
- syncAccounts: 'provider, accountId, email, expiresAt',
- syncProjects: '[projectId+provider], projectId, provider, revision, remoteFileId, lastSyncedAt',
- });
-
- // Version 6 - Add condition definitions table
- this.version(6).stores({
- projects: 'id, name, createdAt, modifiedAt',
- dialogues: 'id, projectId, name, createdAt, modifiedAt',
- participants: 'id, projectId, name, category',
- categories: 'id, projectId, name, parentCategoryId',
- decorators: 'id, projectId, name, type',
- conditions: 'id, projectId, name, type',
- nodes: '[dialogueId+id], dialogueId, type',
- edges: '[dialogueId+id], dialogueId, source, target',
- syncAccounts: 'provider, accountId, email, expiresAt',
- syncProjects: '[projectId+provider], projectId, provider, revision, remoteFileId, lastSyncedAt',
- });
-
- // Version 7 - Add localized strings StringTable
- this.version(7).stores({
- projects: 'id, name, createdAt, modifiedAt',
- dialogues: 'id, projectId, name, createdAt, modifiedAt',
- participants: 'id, projectId, name, category',
- categories: 'id, projectId, name, parentCategoryId',
- decorators: 'id, projectId, name, type',
- conditions: 'id, projectId, name, type',
- nodes: '[dialogueId+id], dialogueId, type',
- edges: '[dialogueId+id], dialogueId, source, target',
- syncAccounts: 'provider, accountId, email, expiresAt',
- syncProjects: '[projectId+provider], projectId, provider, revision, remoteFileId, lastSyncedAt',
- localizedStrings:
- '[projectId+key], projectId, dialogueId, nodeId, rowId, field, modifiedAt',
- });
-
- // Version 8 - Add sync deletions tombstone table
- this.version(8).stores({
- projects: 'id, name, createdAt, modifiedAt',
- dialogues: 'id, projectId, name, createdAt, modifiedAt',
- participants: 'id, projectId, name, category',
- categories: 'id, projectId, name, parentCategoryId',
- decorators: 'id, projectId, name, type',
- conditions: 'id, projectId, name, type',
- nodes: '[dialogueId+id], dialogueId, type',
- edges: '[dialogueId+id], dialogueId, source, target',
- syncAccounts: 'provider, accountId, email, expiresAt',
- syncProjects: '[projectId+provider], projectId, provider, revision, remoteFileId, lastSyncedAt',
- syncDeletions: '[projectId+provider], projectId, provider, deletedAt',
- localizedStrings:
- '[projectId+key], projectId, dialogueId, nodeId, rowId, field, modifiedAt',
- });
-
- // Version 9 - Add sync catalog state and tombstone ledger tables
- this.version(9).stores({
- projects: 'id, name, createdAt, modifiedAt',
- dialogues: 'id, projectId, name, createdAt, modifiedAt',
- participants: 'id, projectId, name, category',
- categories: 'id, projectId, name, parentCategoryId',
- decorators: 'id, projectId, name, type',
- conditions: 'id, projectId, name, type',
- nodes: '[dialogueId+id], dialogueId, type',
- edges: '[dialogueId+id], dialogueId, source, target',
- syncAccounts: 'provider, accountId, email, expiresAt',
- syncProjects: '[projectId+provider], projectId, provider, revision, remoteFileId, lastSyncedAt',
- syncDeletions: '[projectId+provider], projectId, provider, deletedAt',
- syncCatalogState: '[provider+profileId], provider, profileId, catalogRevision, fetchedAt, status',
- syncTombstones: '[provider+entityType+entityId], provider, entityType, entityId, projectId, deletedAt, expiresAt, pending, acknowledgedAt',
- localizedStrings:
- '[projectId+key], projectId, dialogueId, nodeId, rowId, field, modifiedAt',
- });
- this.projects = this.table('projects');
- this.dialogues = this.table('dialogues');
- this.participants = this.table('participants');
- this.categories = this.table('categories');
- this.decorators = this.table('decorators');
- this.conditions = this.table('conditions');
- this.nodes = this.table('nodes');
- this.edges = this.table('edges');
- this.syncAccounts = this.table('syncAccounts');
- this.syncProjects = this.table('syncProjects');
- this.syncDeletions = this.table('syncDeletions');
- this.syncCatalogState = this.table('syncCatalogState');
- this.syncTombstones = this.table('syncTombstones');
- this.localizedStrings = this.table('localizedStrings');
+ constructor(sourceName = 'MounteaDialoguerDB', options = {}) {
+ super(`${sourceName}__generation2`);
+ this.sourceName = sourceName;
+ this.version(1).stores(DATABASE_SCHEMA);
+ for (const name of Object.keys(DATABASE_SCHEMA)) this[name] = this.table(name);
+ this.on('ready', () => migrateLegacyDatabase(this, sourceName, options), true);
}
}
-
-function sanitizeProfileIdForDb(rawValue) {
- const value = String(rawValue || '').trim();
- if (!value) return 'local';
- return value.replace(/[^a-zA-Z0-9_-]/g, '-');
+export function getDatabaseNameForProfile(profileId = getActiveProfileId()) {
+ const normalized = String(profileId || 'local');
+ if (!/^[a-zA-Z0-9_-]+$/.test(normalized)) throw new Error('Invalid profile identifier; refusing an ambiguous database name.');
+ return normalized === 'local' ? 'MounteaDialoguerDB' : `MounteaDialoguerDB__${normalized}`;
}
-
-function getDatabaseNameForProfile(profileId) {
- const normalizedProfileId = sanitizeProfileIdForDb(profileId);
- if (normalizedProfileId === 'local') {
- return 'MounteaDialoguerDB';
- }
- return `MounteaDialoguerDB__${normalizedProfileId}`;
+const instances = new Map();
+let activeController = new AbortController();
+subscribeProfileChanges(() => { activeController.abort(); activeController = new AbortController(); });
+function instanceFor(profileId = getActiveProfileId()) {
+ const name = getDatabaseNameForProfile(profileId);
+ if (!instances.has(name)) instances.set(name, new MounteaDialoguerDB(name));
+ return instances.get(name);
}
-
-function resolveActiveProfileId() {
- try {
- return sanitizeProfileIdForDb(getActiveProfileId());
- } catch (error) {
- return 'local';
- }
+// Legacy single-step access remains compatible. Multi-step callers capture a
+// context and use its database for the entire operation, never the moving proxy.
+export const db = new Proxy({}, {
+ get(_target, property) { const instance = instanceFor(); const value = instance[property]; return typeof value === 'function' ? value.bind(instance) : value; },
+ set(_target, property, value) { instanceFor()[property] = value; return true; },
+});
+export function getProfileScopedDbName() { return instanceFor().name; }
+export async function initializeRepository(profileId = getActiveProfileId()) {
+ const instance = instanceFor(profileId); await instance.open(); return instance;
}
-
-let activeDbInstance = null;
-let activeDbProfileId = '';
-
-function ensureDbInstance() {
- const profileId = resolveActiveProfileId();
- if (!activeDbInstance || activeDbProfileId !== profileId) {
- if (activeDbInstance) {
- try {
- activeDbInstance.close();
- } catch (error) {
- // Swallow close errors; switching profile should be best-effort.
- }
+export async function getRepositoryContext() {
+ const profileId = getActiveProfileId(), generation = getProfileGeneration(), signal = activeController.signal;
+ const instance = instanceFor(profileId);
+ const assertCurrent = () => {
+ if (signal.aborted || generation !== getProfileGeneration() || profileId !== getActiveProfileId()) {
+ const error = new Error('The active profile changed. Retry this operation in the current profile.');
+ error.code = 'STALE_PROFILE'; throw error;
}
- activeDbInstance = new MounteaDialoguerDB(getDatabaseNameForProfile(profileId));
- activeDbProfileId = profileId;
- }
- return activeDbInstance;
+ };
+ await instance.open(); assertCurrent();
+ return Object.freeze({ db: instance, profileId, generation, signal, assertCurrent });
+}
+export async function readProjectRecords(projectId, context) {
+ const captured = context || await getRepositoryContext(); captured.assertCurrent();
+ const result = await captured.db.transaction('r', AUTHORING_TABLES.map((name) => captured.db.table(name)), async () => {
+ const project = await captured.db.projects.get(projectId);
+ const dialogues = await captured.db.dialogues.where('projectId').equals(projectId).toArray();
+ const dialogueIds = dialogues.map((row) => row.id);
+ const records = { projects: project ? [project] : [], dialogues };
+ for (const table of AUTHORING_TABLES.filter((name) => !['projects', 'dialogues'].includes(name))) records[table] = await (['nodes', 'edges'].includes(table) ? captured.db[table].where('dialogueId').anyOf(dialogueIds) : captured.db[table].where('projectId').equals(projectId)).toArray();
+ return records;
+ });
+ captured.assertCurrent(); return result;
+}
+export async function getRecoveryDiagnostics(projectId, context) {
+ const captured = context || await getRepositoryContext(); captured.assertCurrent();
+ const records = await captured.db.recoveryRecords.filter((record) => record.status !== 'resolved' && (!projectId || !record.projectId || record.projectId === projectId)).toArray();
+ captured.assertCurrent(); return records;
+}
+export async function assertProjectReady(projectId, context) {
+ const diagnostics = await getRecoveryDiagnostics(projectId, context);
+ if (diagnostics.length) { const error = new Error('Repair unresolved migration references before exporting or synchronizing this project.'); error.code = 'PROJECT_REPAIR_REQUIRED'; error.diagnostics = diagnostics; throw error; }
}
-export const db = new Proxy(
- {},
- {
- get(_target, property) {
- const instance = ensureDbInstance();
- const value = instance[property];
- if (typeof value === 'function') {
- return value.bind(instance);
- }
- return value;
- },
- set(_target, property, value) {
- const instance = ensureDbInstance();
- instance[property] = value;
- return true;
- },
- }
-);
+/** After repairing references through the normal authoring UI, revalidate them
+ * atomically. Historical diagnostics/originals remain available for recovery;
+ * callers cannot simply dismiss a still-invalid reference. Quarantined records
+ * require explicit restoration before their diagnostic can be cleared. */
+export async function revalidateProjectRecovery(projectId, context) {
+ const captured = context || await getRepositoryContext(); captured.assertCurrent();
+ const result = await captured.db.transaction('rw', [...AUTHORING_TABLES, 'recoveryRecords'].map((name) => captured.db.table(name)), async () => {
+ const records = await readProjectRecords(projectId, captured);
+ const { diagnostics } = transformLegacyRecords(records);
+ const previous = await captured.db.recoveryRecords.where('projectId').equals(projectId).toArray();
+ const locationKey = (record) => JSON.stringify([record.table, record.recordKey ?? (['nodes', 'edges'].includes(record.table) ? [record.original?.dialogueId, record.original?.id] : record.original?.id), record.path, record.rowId || null]);
+ const unresolved = new Map(diagnostics.map((record) => [locationKey(record), record]));
+ for (const record of previous) {
+ if (['duplicate_identity', 'unknown_table', 'missing_project', 'missing_dialogue'].includes(record.code)) continue;
+ const key = record.recordKey ?? (['nodes', 'edges'].includes(record.table) ? [record.original?.dialogueId, record.original?.id] : record.original?.id);
+ const candidates = records[record.table] || [];
+ const candidate = candidates.find((candidate) => Array.isArray(key) ? candidate.id === key[1] && candidate.dialogueId === key[0] : candidate.id === key);
+ if (!candidate || (record.rowId && !candidate.data?.dialogueRows?.some((row) => row.id === record.rowId))) continue;
+ const location = locationKey(record);
+ if (unresolved.has(location)) { unresolved.delete(location); continue; }
+ if (record.status !== 'resolved') await captured.db.recoveryRecords.update(record.id, { status: 'resolved', resolvedAt: new Date().toISOString() });
+ }
+ if (unresolved.size) await captured.db.recoveryRecords.bulkAdd([...unresolved.values()]);
+ captured.assertCurrent(); return diagnostics;
+ });
+ captured.assertCurrent(); return result;
+}
-export function getProfileScopedDbName() {
- return ensureDbInstance().name;
+export async function restoreQuarantinedNode(diagnosticId, restoredNode, context) {
+ const captured = context || await getRepositoryContext(); captured.assertCurrent();
+ const diagnostic = await captured.db.recoveryRecords.get(diagnosticId); captured.assertCurrent();
+ if (diagnostic?.code !== 'duplicate_identity' || diagnostic.table !== 'nodes' || diagnostic.status === 'resolved') throw new Error('Select an unresolved quarantined node.');
+ const node = structuredClone(restoredNode);
+ const { mutateProject } = await import('@/lib/persistence/projectRepository'); captured.assertCurrent();
+ await mutateProject(diagnostic.projectId, { context: captured, recoveryResolutions: [{ id: diagnosticId, restoredKey: [node.dialogueId, node.id] }], transform: (snapshot) => {
+ if (!node.id || !node.type || !node.data || !snapshot.dialogues.some((dialogue) => dialogue.id === node.dialogueId)) throw new Error('The restored node must have an identity, type, data, and a dialogue in the affected project.');
+ if (['start', 'startNode'].includes(node.type)) throw new Error('The dialogue already has a Start node. Choose the intended non-Start type for the recovered node.');
+ if (snapshot.nodes.some((existing) => existing.dialogueId === node.dialogueId && existing.id === node.id)) throw new Error('Choose a distinct node identity before restoring this record.');
+ snapshot.nodes.push(node);
+ } });
+ return revalidateProjectRecovery(diagnostic.projectId, captured);
}
+/** Explicit target selection; never guess between equal display names. */
+export async function repairIdentityReference(diagnosticId, targetId, context) {
+ const captured = context || await getRepositoryContext();
+ const diagnostic = await captured.db.recoveryRecords.get(diagnosticId); captured.assertCurrent();
+ if (!diagnostic || diagnostic.status === 'resolved' || !['missing_reference', 'ambiguous_reference'].includes(diagnostic.code)) throw new Error('Select an unresolved identity reference.');
+ const { mutateProject } = await import('@/lib/persistence/projectRepository');
+ const { normalizeParticipant } = await import('@/lib/domainIntegrity'); captured.assertCurrent();
+ await mutateProject(diagnostic.projectId, { context: captured, transform: (snapshot) => {
+ if (diagnostic.table === 'participants' && diagnostic.path === 'categoryId') {
+ const record = snapshot.participants.find((item) => item.id === (diagnostic.recordKey || diagnostic.original.id));
+ if (!record) throw new Error('The original participant is unavailable; retain the evidence for recovery.');
+ Object.assign(record, normalizeParticipant({ ...record, categoryId: targetId }, snapshot, record.id));
+ } else if (diagnostic.table === 'nodes' && (diagnostic.path === 'participantId' || diagnostic.rowId)) {
+ const key = diagnostic.recordKey || [diagnostic.original.dialogueId, diagnostic.original.id];
+ const node = snapshot.nodes.find((item) => item.id === key[1] && item.dialogueId === key[0]);
+ const target = snapshot.participants.find((item) => item.id === targetId);
+ if (!node || !target) throw new Error('Choose a participant and node in this project.');
+ const data = diagnostic.rowId ? node.data.dialogueRows?.find((row) => row.id === diagnostic.rowId) : node.data;
+ if (!data) throw new Error('The affected row is unavailable; retain the evidence for recovery.');
+ data.participantId = target.id; data.participant = target.name;
+ } else throw new Error('Repair this reference in its authoring editor, then revalidate.');
+ } });
+ return revalidateProjectRecovery(diagnostic.projectId, captured);
+}
diff --git a/src/lib/dialogueImportAudio.test.js b/src/lib/dialogueImportAudio.test.js
deleted file mode 100644
index f4278f4..0000000
--- a/src/lib/dialogueImportAudio.test.js
+++ /dev/null
@@ -1,63 +0,0 @@
-/* eslint-env jest */
-import {
- getScopedRowAudioFilePaths,
- resolveRowAudioImportSelection,
-} from './dialogueImportAudio';
-
-function createMockRowFolder(allEntries, rowId) {
- const prefix = `audio/${rowId}/`;
- return {
- filter: (predicate) =>
- allEntries
- .filter((entry) => String(entry.name || '').startsWith(prefix))
- .filter((entry) =>
- predicate(entry.name.slice(prefix.length), {
- name: entry.name,
- dir: Boolean(entry.dir),
- })
- )
- .map((entry) => ({ name: entry.name, dir: Boolean(entry.dir) })),
- };
-}
-
-describe('dialogueImportAudio', () => {
- test('selects only files directly under the requested row folder', () => {
- const rowFolder = createMockRowFolder([
- { name: 'audio/row-a/Line A.mp3', dir: false },
- { name: 'audio/row-b/Line B.mp3', dir: false },
- { name: 'audio/row-a/nested/skip.mp3', dir: false },
- { name: 'stringTable.json', dir: false },
- ], 'row-a');
-
- expect(getScopedRowAudioFilePaths(rowFolder)).toEqual([
- 'audio/row-a/Line A.mp3',
- ]);
- });
-
- test('returns deterministic first file and warning when multiple files exist', () => {
- const rowFolder = createMockRowFolder([
- { name: 'audio/row-a/z-last.mp3', dir: false },
- { name: 'audio/row-a/a-first.mp3', dir: false },
- ], 'row-a');
-
- expect(resolveRowAudioImportSelection(rowFolder, 'row-a')).toEqual({
- selectedPath: 'audio/row-a/a-first.mp3',
- warnings: [
- {
- code: 'multiple_row_audio',
- rowId: 'row-a',
- paths: ['audio/row-a/a-first.mp3', 'audio/row-a/z-last.mp3'],
- },
- ],
- });
- });
-
- test('returns missing warning when row folder has no direct audio file', () => {
- const rowFolder = createMockRowFolder([{ name: 'audio/row-b/Other.mp3', dir: false }], 'row-a');
-
- expect(resolveRowAudioImportSelection(rowFolder, 'row-a')).toEqual({
- selectedPath: '',
- warnings: [{ code: 'missing_row_audio', rowId: 'row-a' }],
- });
- });
-});
diff --git a/src/lib/domainIntegrity.js b/src/lib/domainIntegrity.js
new file mode 100644
index 0000000..186c01b
--- /dev/null
+++ b/src/lib/domainIntegrity.js
@@ -0,0 +1,152 @@
+export class DomainIntegrityError extends Error {
+ constructor(code, message, references = []) { super(message); this.name = 'DomainIntegrityError'; this.code = code; this.references = references; }
+}
+const fail = (code, message, references) => { throw new DomainIntegrityError(code, message, references); };
+
+export function categoryAncestors(categoryId, categories) {
+ const byId = new Map(categories.map((record) => [record.id, record]));
+ const chain = [], visited = new Set();
+ let id = categoryId;
+ while (id) {
+ if (visited.has(id)) fail('CATEGORY_CYCLE', 'Category hierarchy contains a cycle.');
+ visited.add(id);
+ const record = byId.get(id);
+ if (!record) fail('CATEGORY_PARENT_MISSING', 'Category parent must belong to this project.');
+ chain.push(record); id = record.parentCategoryId;
+ }
+ return chain;
+}
+export function buildCategoryPath(id, categories) { return categoryAncestors(id, categories).reverse().map((record) => record.name).join('.'); }
+export function getRootCategoryId(id, categories) { return categoryAncestors(id, categories).at(-1)?.id || null; }
+export function validateEntityName(name, kind) {
+ const value = String(name || '').trim();
+ if (!/^[A-Za-z0-9]{1,16}$/.test(value)) fail('INVALID_NAME', `${kind} name must contain 1–16 letters or numbers.`);
+ return value;
+}
+export function validateCategoryTree(categories, projectId) {
+ const names = new Set(), roots = new Set();
+ for (const category of categories) {
+ if (category.projectId !== projectId) fail('FOREIGN_OWNER', 'Category must belong to this project.');
+ validateEntityName(category.name, 'Category');
+ const ancestors = categoryAncestors(category.id, categories);
+ if (ancestors.length > 5) fail('CATEGORY_DEPTH', 'Category depth cannot exceed 5 levels.');
+ const root = ancestors.at(-1);
+ const identity = `${root.id}:${category.name}`;
+ if (names.has(identity)) fail('DUPLICATE_CATEGORY_NAME', 'Category name must be unique within its tree.');
+ names.add(identity);
+ if (!category.parentCategoryId) {
+ if (roots.has(category.name)) fail('DUPLICATE_CATEGORY_NAME', 'Root category names must be unique.');
+ roots.add(category.name);
+ }
+ }
+}
+export function prepareCategoryImport(projectId, existing, inputs, createId = () => crypto.randomUUID()) {
+ const categories = existing.map((record) => ({ ...record })), created = [];
+ validateCategoryTree(categories, projectId);
+ const byPath = new Map(categories.map((record) => [buildCategoryPath(record.id, categories), record.id]));
+ for (const input of inputs) {
+ const parts = String(input.fullPath || input.name || '').split('.');
+ let path = '', parentCategoryId = null;
+ for (const part of parts) {
+ const name = validateEntityName(part, 'Category');
+ path = path ? `${path}.${name}` : name;
+ if (byPath.has(path)) { parentCategoryId = byPath.get(path); continue; }
+ const now = new Date().toISOString();
+ const record = { id: createId(), projectId, name, parentCategoryId, createdAt: now, modifiedAt: now };
+ categories.push(record); created.push(record); byPath.set(path, record.id); parentCategoryId = record.id;
+ }
+ }
+ validateCategoryTree(categories, projectId);
+ return { categories, created };
+}
+export function resolveReference(records, id, legacyName, kind, { optional = false } = {}) {
+ if (id) {
+ const record = records.find((candidate) => candidate.id === id);
+ if (!record) fail('REFERENCE_MISSING', `${kind} ${id} does not belong to this project.`);
+ return record;
+ }
+ if (!legacyName && optional) return null;
+ const matches = records.filter((candidate) => candidate.name === legacyName);
+ if (matches.length !== 1) fail(matches.length ? 'AMBIGUOUS_REFERENCE' : 'REFERENCE_MISSING', `Choose a ${kind.toLowerCase()} by identity; the legacy name "${legacyName || ''}" is ${matches.length ? 'ambiguous' : 'missing'}.`);
+ return matches[0];
+}
+export function normalizeParticipant(record, snapshot, excludeId) {
+ const name = validateEntityName(record.name, 'Participant');
+ const category = resolveReference(snapshot.categories, record.categoryId, record.category, 'Category');
+ const root = getRootCategoryId(category.id, snapshot.categories);
+ for (const existing of snapshot.participants) {
+ if (existing.id === excludeId || existing.name !== name) continue;
+ const existingCategory = resolveReference(snapshot.categories, existing.categoryId, existing.category, 'Category');
+ if (getRootCategoryId(existingCategory.id, snapshot.categories) === root) fail('DUPLICATE_PARTICIPANT_NAME', 'Participant name must be unique within its category tree.');
+ }
+ return { ...record, name, categoryId: category.id, category: category.name };
+}
+export function validateDefinition(definition) {
+ if (!String(definition.name || '').trim()) fail('INVALID_DEFINITION', 'Definition name is required.');
+ if (!Array.isArray(definition.properties || [])) fail('INVALID_DEFINITION', 'Definition properties must be an array.');
+ const seen = new Set();
+ for (const property of definition.properties || []) {
+ const name = String(property.name || '').trim();
+ if (!name || name !== property.name || seen.has(name) || ['__proto__', 'constructor', 'prototype'].includes(name)) fail('INVALID_PROPERTY_NAME', 'Property names must be nonempty, unique, and safe object keys.');
+ seen.add(name);
+ if (!['string', 'number', 'boolean'].includes(property.type)) fail('INVALID_PROPERTY_TYPE', `Unsupported property type: ${property.type}`);
+ if (property.defaultValue !== undefined && property.defaultValue !== null && (typeof property.defaultValue !== property.type || (property.type === 'number' && !Number.isFinite(property.defaultValue)))) fail('INVALID_PROPERTY_DEFAULT', `Default for ${name} must have type ${property.type}.`);
+ }
+}
+export function findEntityReferences(snapshot, kind, record) {
+ const refs = [];
+ const add = (table, item, path) => refs.push({ table, id: item.id, dialogueId: item.dialogueId, path });
+ if (kind === 'categories') {
+ for (const child of snapshot.categories) if (child.parentCategoryId === record.id) add('categories', child, 'parentCategoryId');
+ for (const participant of snapshot.participants) if (participant.categoryId === record.id || (!participant.categoryId && participant.category === record.name)) add('participants', participant, 'categoryId');
+ }
+ for (const node of snapshot.nodes) {
+ if (kind === 'participants') {
+ if (node.data?.participantId === record.id || (!node.data?.participantId && node.data?.participant === record.name)) add('nodes', node, 'data.participantId');
+ for (const row of node.data?.dialogueRows || []) if (row.participantId === record.id || (!row.participantId && row.participant === record.name)) add('nodes', node, `data.dialogueRows.${row.id}.participantId`);
+ }
+ if (kind === 'decorators') for (const instance of node.data?.decorators || []) if (instance.id === record.id || (!instance.id && instance.name === record.name)) add('nodes', node, 'data.decorators');
+ if (kind === 'dialogues' && node.data?.targetDialogue === record.id && node.dialogueId !== record.id) add('nodes', node, 'data.targetDialogue');
+ if (kind === 'nodes' && node.dialogueId === record.dialogueId && node.data?.targetNode === record.id && node.id !== record.id) add('nodes', node, 'data.targetNode');
+ }
+ if (kind === 'conditions') for (const edge of snapshot.edges) for (const rule of edge.data?.conditions?.rules || []) if (rule.id === record.id || (!rule.id && rule.name === record.name)) add('edges', edge, 'data.conditions.rules');
+ return refs;
+}
+export function assertUnreferenced(snapshot, kind, record) {
+ const references = findEntityReferences(snapshot, kind, record);
+ if (references.length) fail('RECORD_REFERENCED', `Remove or reassign ${references.length} reference(s) before deleting ${record.name || record.id}. ${references.map((ref) => `${ref.table}/${ref.id}: ${ref.path}`).join('; ')}`, references);
+}
+export function assertCompatibleDefinition(snapshot, kind, previous, next) {
+ validateDefinition(next);
+ const references = findEntityReferences(snapshot, kind, previous);
+ if (!references.length) return;
+ const changed = (previous.properties || []).some((property) => !next.properties?.some((candidate) => candidate.name === property.name && candidate.type === property.type));
+ if (changed) fail('DEFINITION_REFERENCED', `Remove or reassign ${references.length} reference(s) before removing, renaming or changing property types. ${references.map((ref) => `${ref.table}/${ref.id}`).join(', ')}`, references);
+}
+export function projectIdentityLabels(snapshot, { strict = true } = {}) {
+ const resolve = (...args) => { try { return resolveReference(...args); } catch (error) { if (strict) throw error; return null; } };
+ for (const participant of snapshot.participants) {
+ const category = resolve(snapshot.categories, participant.categoryId, participant.category, 'Category');
+ if (category) { participant.categoryId = category.id; participant.category = category.name; }
+ }
+ for (const node of snapshot.nodes) {
+ for (const data of [node.data, ...(node.data?.dialogueRows || [])].filter(Boolean)) {
+ const participant = resolve(snapshot.participants, data.participantId, data.participant, 'Participant', { optional: true });
+ if (participant) { data.participantId = participant.id; data.participant = participant.name; }
+ }
+ for (const instance of node.data?.decorators || []) {
+ const definition = resolve(snapshot.decorators, instance.id, instance.name, 'Decorator');
+ if (definition) { instance.id = definition.id; instance.name = definition.name; }
+ }
+ }
+ for (const edge of snapshot.edges) for (const rule of edge.data?.conditions?.rules || []) {
+ const definition = resolve(snapshot.conditions, rule.id, rule.name, 'Condition');
+ if (definition) { rule.id = definition.id; rule.name = definition.name; }
+ }
+ return snapshot;
+}
+
+export function assertGraphNodesRemovable(nodes, removedIds) {
+ const references = nodes.filter((node) => !removedIds.has(node.id) && removedIds.has(node.data?.targetNode)).map((node) => ({ table: 'nodes', id: node.id, path: 'data.targetNode' }));
+ if (references.length) fail('RECORD_REFERENCED', `Reassign return targets before deleting: ${references.map((ref) => ref.id).join(', ')}`, references);
+}
diff --git a/src/lib/editorDraft.js b/src/lib/editorDraft.js
new file mode 100644
index 0000000..378b986
--- /dev/null
+++ b/src/lib/editorDraft.js
@@ -0,0 +1,71 @@
+// Durable graph drafts retain binary attachments and unknown authored fields.
+// Runtime callbacks, object URLs and React Flow interaction state are never history.
+function cleanValue(value) {
+ if (typeof value === 'function' || typeof value === 'symbol') return undefined;
+ if (!value || typeof value !== 'object' || value instanceof Blob || value instanceof Date || value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return value;
+ if (Array.isArray(value)) return value.map(cleanValue);
+ return Object.fromEntries(Object.entries(value).flatMap(([key, item]) => {
+ if ((key === 'url' || key === 'src') && typeof item === 'string' && item.startsWith('blob:')) return [];
+ const cleaned = cleanValue(item);
+ return cleaned === undefined ? [] : [[key, cleaned]];
+ }));
+}
+
+export function cloneGraphDraft(nodes = [], edges = []) {
+ const cleanRecord = (record) => {
+ const result = cleanValue(record);
+ for (const key of ['selected', 'dragging', 'resizing', 'measured', 'positionAbsolute']) delete result[key];
+ return result;
+ };
+ const regularNodes = nodes.filter((node) => node.type !== 'placeholderNode');
+ const ids = new Set(regularNodes.map((node) => node.id));
+ return structuredClone({
+ nodes: regularNodes.map(cleanRecord),
+ edges: edges.filter((edge) => !edge.data?.isPlaceholder && ids.has(edge.source) && ids.has(edge.target)).map(cleanRecord),
+ });
+}
+
+export function createEditorDraft(nodes, edges, generation = 0) {
+ return { nodes, edges, history: [cloneGraphDraft(nodes, edges)], historyIndex: 0, revision: 0, savedRevision: 0, generation };
+}
+
+export function editorDraftReducer(state, action) {
+ switch (action.type) {
+ case 'nodes': return { ...state, nodes: action.value };
+ case 'edges': return { ...state, edges: action.value };
+ case 'dirty': return { ...state, history: state.history.slice(0, state.historyIndex + 1), revision: state.revision + 1 };
+ case 'reset': return createEditorDraft(action.nodes, action.edges, state.generation + 1);
+ case 'saved': return action.ticket.generation === state.generation
+ ? { ...state, savedRevision: Math.max(state.savedRevision, action.ticket.revision) } : state;
+ case 'checkpoint': {
+ const history = [...state.history.slice(0, state.historyIndex + 1), cloneGraphDraft(action.nodes, action.edges)].slice(-50);
+ return { ...state, history, historyIndex: history.length - 1 };
+ }
+ case 'undo':
+ case 'redo': {
+ const index = state.historyIndex + (action.type === 'undo' ? -1 : 1);
+ if (index < 0 || index >= state.history.length) return state;
+ const graph = cloneGraphDraft(state.history[index].nodes, state.history[index].edges);
+ return { ...state, ...graph, historyIndex: index, revision: state.revision + 1 };
+ }
+ case 'nodeData': {
+ const node = state.nodes.find((candidate) => candidate.id === action.id);
+ if (!node || !Object.entries(action.data).some(([key, value]) => node.data?.[key] !== value)) return state;
+ return { ...state, history: state.history.slice(0, state.historyIndex + 1), revision: state.revision + 1, nodes: state.nodes.map((candidate) => candidate === node ? { ...node, data: { ...node.data, ...action.data } } : candidate) };
+ }
+ default: return state;
+ }
+}
+
+export function createDecoratorInstance(definition) {
+ return { id: definition.id, name: definition.name, values: Object.fromEntries((definition.properties || []).map((property) => [property.name, property.defaultValue ?? ''])) };
+}
+
+export function createEditorSaveQueue() {
+ let tail = Promise.resolve();
+ return (operation) => {
+ const result = tail.then(operation);
+ tail = result.catch(() => {});
+ return result;
+ };
+}
diff --git a/src/lib/errorPresentation.js b/src/lib/errorPresentation.js
new file mode 100644
index 0000000..b3ac28a
--- /dev/null
+++ b/src/lib/errorPresentation.js
@@ -0,0 +1,28 @@
+import i18n from '@/i18n';
+
+const groups = {
+ STALE_PROFILE: 'stale', STALE_PROJECT: 'stale', PROJECT_EXISTS: 'exists', PROJECT_NOT_FOUND: 'missing',
+ RECORD_REFERENCED: 'referenced', DEFINITION_REFERENCED: 'referenced',
+ CATEGORY_CYCLE: 'categoryCycle', CATEGORY_DEPTH: 'categoryDepth', CATEGORY_PARENT_MISSING: 'reference',
+ REFERENCE_MISSING: 'reference', AMBIGUOUS_REFERENCE: 'reference', FOREIGN_OWNER: 'reference', FOREIGN_OWNERSHIP: 'reference',
+ INVALID_NAME: 'name', DUPLICATE_CATEGORY_NAME: 'duplicateName', DUPLICATE_PARTICIPANT_NAME: 'duplicateName',
+ INVALID_DEFINITION: 'definition', INVALID_PROPERTY_NAME: 'definition', INVALID_PROPERTY_TYPE: 'definition', INVALID_PROPERTY_DEFAULT: 'definition', INCOMPATIBLE_SCHEMA: 'definition',
+ LEGACY_REFERENCE_REPAIR_REQUIRED: 'repair', PROJECT_REPAIR_REQUIRED: 'repair', LOCALIZATION_REPAIR_REQUIRED: 'repair', INVALID_PROJECT_DATA: 'repair',
+ MISSING_MEDIA: 'media', INVALID_MEDIA: 'media', MEDIA_SIZE_MISMATCH: 'media',
+ missing_reference: 'reference', ambiguous_reference: 'reference', duplicate_identity: 'duplicateIdentity', missing_project: 'missing', missing_dialogue: 'missing',
+};
+
+/** Translate presentation, retaining structured record identities and paths as evidence. */
+export function describeError(error, t = i18n.t.bind(i18n)) {
+ const code = String(error?.code || '');
+ const group = groups[code] || (/^ARCHIVE_.*_LIMIT$/.test(code) ? 'archiveLimit' : /^INVALID_ARCHIVE/.test(code) || code === 'UNSAFE_ARCHIVE_PATH' ? 'archiveCorrupt' : /^UNSUPPORTED_ARCHIVE/.test(code) ? 'archiveUnsupported' : error?.name === 'QuotaExceededError' ? 'quota' : 'unexpected');
+ const description = t(`errors.details.${group}`);
+ const evidence = [...(error?.references || []), ...(error?.diagnostics || []), error || {}]
+ .map(record => ['code', 'table', 'id', 'projectId', 'dialogueId', 'nodeId', 'rowId', 'key', 'path'].map(key => record?.[key]).filter(value => typeof value === 'string' && value).join(' · '))
+ .filter(Boolean);
+ return evidence.length ? `${description} ${[...new Set(evidence)].join('; ')}` : description;
+}
+
+export function errorToast(error, action = 'update') {
+ return { variant: 'error', title: i18n.t(`errors.actions.${action}`), description: describeError(error) };
+}
diff --git a/src/lib/localization/stringTable.js b/src/lib/localization/stringTable.js
index a3698f4..173b95e 100644
--- a/src/lib/localization/stringTable.js
+++ b/src/lib/localization/stringTable.js
@@ -1,3 +1,5 @@
+import dialogueNodeConfig from '@/config/dialogueNodes.json';
+
const DEFAULT_LOCALE = 'en';
export const LOCALIZED_STRING_FIELDS = Object.freeze({
@@ -138,7 +140,6 @@ export function normalizeProjectLocalizationConfig(rawConfig = {}) {
}
return {
- enabled: true,
defaultLocale: rawDefault,
supportedLocales: supported.length > 0 ? supported : [rawDefault],
};
@@ -154,6 +155,24 @@ export function ensureDialogueLocalizationSlug(dialogue = null) {
return slugifyForKeySegment(dialogue?.name || dialogue?.id || 'dialogue', 'dialogue');
}
+/** Allocate against the entire project inside the caller's write transaction.
+ * Keep existing unique namespaces immutable, including across dialogue renames. */
+export function allocateDialogueLocalizationSlug(dialogue, projectDialogues = []) {
+ const base = ensureDialogueLocalizationSlug(dialogue);
+ const used = new Set(projectDialogues.filter((other) => String(other.id) !== String(dialogue.id)).map(ensureDialogueLocalizationSlug));
+ if (!used.has(base)) return base;
+ const identity = slugifyForKeySegment(dialogue.id, '') || shortHash(dialogue.id, 8);
+ const candidate = `${base}_${identity}`;
+ let result = candidate;
+ for (let index = 2; used.has(result); index += 1) result = `${candidate}_${index}`;
+ return result;
+}
+
+export function getLocalizedNodeFields(node) {
+ const configured = (dialogueNodeConfig[node?.type]?.sections || []).flatMap((section) => section.fields || []).filter((field) => field.localizable).map((field) => field.id);
+ return new Set(configured);
+}
+
export function ensureNodeLocalizationToken(node = null) {
const existing = slugifyForKeySegment(node?.data?.localizationNodeToken || '', '');
if (existing && existing !== 'item') return existing;
@@ -436,6 +455,15 @@ export function prepareLocalizedNodesAndEntries({
const seenKeys = new Set();
const seenNodeTokens = new Set();
const nodesForPersistence = [];
+ const assertEntryOwner = (entry, nodeId, rowId, field) => {
+ if (!entry) return;
+ if ((entry.projectId && entry.projectId !== safeProjectId) || (entry.dialogueId && entry.dialogueId !== safeDialogueId) || (entry.nodeId && entry.nodeId !== nodeId) || (entry.rowId && entry.rowId !== rowId) || entry.field !== field) {
+ const error = new Error(`Localization entry belongs to another record: ${entry.key}`);
+ error.code = 'LOCALIZATION_REPAIR_REQUIRED';
+ error.diagnostics = [{ type: 'entry_scope_mismatch', key: entry.key, nodeId, rowId, field }];
+ throw error;
+ }
+ };
for (const rawNode of nodes || []) {
const node = { ...rawNode };
@@ -499,7 +527,8 @@ export function prepareLocalizedNodesAndEntries({
}
seenKeys.add(key);
- const existing = entriesByKey.get(key) || existingByKey.get(key) || null;
+ const existing = entriesByKey.get(key) || existingByKey.get(key) || existingByKey.get(rawNode?.data?.[keyField]) || null;
+ assertEntryOwner(existing, nodeId, rowId, field);
const values = { ...(existing?.values || {}) };
const hasRawValue = Object.prototype.hasOwnProperty.call(rawNode?.data || {}, valueField);
if (hasRawValue) {
@@ -507,6 +536,7 @@ export function prepareLocalizedNodesAndEntries({
}
entriesByKey.set(key, {
+ ...existing,
projectId: safeProjectId,
key,
dialogueId: safeDialogueId,
@@ -523,6 +553,7 @@ export function prepareLocalizedNodesAndEntries({
};
if (
+ getLocalizedNodeFields(rawNode).has('displayName') ||
Object.prototype.hasOwnProperty.call(rawNode?.data || {}, 'displayName') ||
Object.prototype.hasOwnProperty.call(nodeData, 'displayNameKey')
) {
@@ -534,6 +565,7 @@ export function prepareLocalizedNodesAndEntries({
}
if (
+ getLocalizedNodeFields(rawNode).has('selectionTitle') ||
Object.prototype.hasOwnProperty.call(rawNode?.data || {}, 'selectionTitle') ||
Object.prototype.hasOwnProperty.call(nodeData, 'selectionTitleKey')
) {
@@ -602,7 +634,8 @@ export function prepareLocalizedNodesAndEntries({
}
seenKeys.add(rowTextKey);
- const existing = entriesByKey.get(rowTextKey) || existingByKey.get(rowTextKey) || null;
+ const existing = entriesByKey.get(rowTextKey) || existingByKey.get(rowTextKey) || existingByKey.get(rawRow?.textKey) || null;
+ assertEntryOwner(existing, nodeId, rowId, LOCALIZED_STRING_FIELDS.rowText);
const values = { ...(existing?.values || {}) };
const rawSourceRow = Array.isArray(rawNode?.data?.dialogueRows)
? rawNode.data.dialogueRows[rowIndex]
@@ -612,6 +645,7 @@ export function prepareLocalizedNodesAndEntries({
}
entriesByKey.set(rowTextKey, {
+ ...existing,
projectId: safeProjectId,
key: rowTextKey,
dialogueId: safeDialogueId,
@@ -768,59 +802,42 @@ export function buildLocalizedEntriesFromNodes({
}
export function validateLocalizedEntriesForDialogue({
- nodes = [],
- entries = [],
- defaultLocale = DEFAULT_LOCALE,
+ nodes = [], entries = [], defaultLocale = DEFAULT_LOCALE,
+ projectId = '', dialogueId = '', dialogueSlug = '', projectEntries = [],
}) {
const errors = [];
- const byKey = new Map(
- (entries || [])
- .map((entry) => normalizeLocalizedStringEntry(entry))
- .filter((entry) => entry.key)
- .map((entry) => [entry.key, entry])
- );
- const requiredKeys = [];
- const seen = new Set();
-
- for (const node of nodes || []) {
- const nodeData = node?.data || {};
- if (nodeData.displayNameKey) requiredKeys.push(nodeData.displayNameKey);
- if (nodeData.selectionTitleKey) requiredKeys.push(nodeData.selectionTitleKey);
- if (Array.isArray(nodeData.dialogueRows)) {
- for (const row of nodeData.dialogueRows) {
- if (row?.textKey) requiredKeys.push(row.textKey);
- }
- }
+ const byKey = new Map();
+ for (const raw of entries) {
+ const entry = normalizeLocalizedStringEntry(raw);
+ if (byKey.has(entry.key)) errors.push({ type: 'duplicate_entry_key', key: entry.key });
+ byKey.set(entry.key, entry);
}
-
- for (const key of requiredKeys) {
- if (!key) {
- errors.push({ type: 'missing_key_ref' });
- continue;
- }
- if (!(READABLE_KEY_REGEX.test(key) || LEGACY_ROW_KEY_REGEX.test(key) || LEGACY_NODE_KEY_REGEX.test(key))) {
- errors.push({ type: 'invalid_key_format', key });
- }
- if (seen.has(key)) {
- errors.push({ type: 'duplicate_key_ref', key });
- }
+ const seen = new Set();
+ const normalizedDefault = normalizeLocaleTag(defaultLocale, DEFAULT_LOCALE);
+ const check = (node, field, key, row = null) => {
+ const scope = { nodeId: String(node.id), rowId: String(row?.id || ''), field };
+ if (!key) { errors.push({ type: 'missing_key_ref', ...scope }); return; }
+ const parsed = parseLocalizedStringKey(key);
+ if (!parsed) errors.push({ type: 'invalid_key_format', key, ...scope });
+ if (seen.has(key)) errors.push({ type: 'duplicate_key_ref', key, ...scope });
seen.add(key);
-
+ if (parsed && (parsed.field !== field || (parsed.keyType === 'legacy' && ((dialogueId && parsed.dialogueId !== String(dialogueId)) || parsed.nodeId !== scope.nodeId || (row && parsed.rowId !== scope.rowId))) || (parsed.keyType === 'readable' && ((dialogueSlug && parsed.dialogueSlug !== dialogueSlug) || parsed.nodeToken !== ensureNodeLocalizationToken(node) || (row && parsed.rowToken !== ensureRowLocalizationToken(row)))))) errors.push({ type: 'key_scope_mismatch', key, ...scope });
const entry = byKey.get(key);
- if (!entry) {
- errors.push({ type: 'missing_entry', key });
- continue;
- }
- const normalizedDefault = normalizeLocaleTag(defaultLocale, DEFAULT_LOCALE);
- if (!Object.prototype.hasOwnProperty.call(entry.values || {}, normalizedDefault)) {
- errors.push({ type: 'missing_default_locale_value', key, locale: normalizedDefault });
+ if (!entry) { errors.push({ type: 'missing_entry', key, ...scope }); return; }
+ if ((projectId && entry.projectId !== String(projectId)) || (dialogueId && entry.dialogueId !== String(dialogueId)) || ((dialogueId || entry.nodeId) && entry.nodeId !== scope.nodeId) || ((dialogueId || entry.rowId) && entry.rowId !== scope.rowId) || entry.field !== field) errors.push({ type: 'entry_scope_mismatch', key, ...scope });
+ if (projectEntries.some((other) => other.key === key && ((projectId && String(other.projectId) !== String(projectId)) || (dialogueId && String(other.dialogueId) !== String(dialogueId))))) errors.push({ type: 'project_key_collision', key, ...scope });
+ if (!Object.prototype.hasOwnProperty.call(entry.values, normalizedDefault)) errors.push({ type: 'missing_default_locale_value', key, locale: normalizedDefault, ...scope });
+ };
+ for (const node of nodes) {
+ if (node.type === 'placeholderNode') continue;
+ const data = node.data || {};
+ const fields = getLocalizedNodeFields(node);
+ for (const field of ['displayName', 'selectionTitle']) {
+ if (fields.has(field) || Object.prototype.hasOwnProperty.call(data, field) || Object.prototype.hasOwnProperty.call(data, `${field}Key`)) check(node, field, data[`${field}Key`]);
}
+ for (const row of data.dialogueRows || []) check(node, 'rowText', row?.textKey, row);
}
-
- return {
- valid: errors.length === 0,
- errors,
- };
+ return { valid: errors.length === 0, errors };
}
export function buildStringTableV2Payload({
diff --git a/src/lib/monitoring/config.js b/src/lib/monitoring/config.js
new file mode 100644
index 0000000..f24960d
--- /dev/null
+++ b/src/lib/monitoring/config.js
@@ -0,0 +1,9 @@
+export function parseSampleRate(value, fallback = 0.1) {
+ if (value == null || String(value).trim() === '') return fallback;
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? Math.min(1, Math.max(0, parsed)) : fallback;
+}
+
+export function rendererReportingEnabled(env) {
+ return Boolean(String(env.VITE_SENTRY_DSN || '').trim()) && (!env.DEV || String(env.VITE_SENTRY_ENABLE_IN_DEV || '') === '1');
+}
diff --git a/src/lib/monitoring/sentry.js b/src/lib/monitoring/sentry.js
index ea36a54..544af41 100644
--- a/src/lib/monitoring/sentry.js
+++ b/src/lib/monitoring/sentry.js
@@ -1,26 +1,24 @@
import * as Sentry from '@sentry/react';
+import { parseSampleRate, rendererReportingEnabled } from './config';
let sentryInitialized = false;
-
-function parseSampleRate(value, fallback = 0.1) {
- const parsed = Number(value);
- return Number.isFinite(parsed) ? parsed : fallback;
-}
+let reportingEnabled = false;
export function initRendererSentry() {
if (sentryInitialized) return false;
const dsn = String(import.meta.env.VITE_SENTRY_DSN || '').trim();
if (!dsn) return false;
+ reportingEnabled = rendererReportingEnabled(import.meta.env);
Sentry.init({
dsn,
environment: String(import.meta.env.MODE || 'production'),
tracesSampleRate: parseSampleRate(import.meta.env.VITE_SENTRY_TRACES_SAMPLE_RATE, 0.1),
- enabled:
- import.meta.env.DEV
- ? String(import.meta.env.VITE_SENTRY_ENABLE_IN_DEV || '') === '1'
- : true,
+ enabled: reportingEnabled,
+ // Injected from the same identity written to dist/release.json.
+ // eslint-disable-next-line no-undef
+ release: __APP_RELEASE__,
});
sentryInitialized = true;
@@ -28,5 +26,5 @@ export function initRendererSentry() {
}
export function isRendererSentryEnabled() {
- return sentryInitialized;
+ return sentryInitialized && reportingEnabled;
}
diff --git a/src/lib/onboarding/templateLoader.js b/src/lib/onboarding/templateLoader.js
index bff3a0c..3e9c24c 100644
--- a/src/lib/onboarding/templateLoader.js
+++ b/src/lib/onboarding/templateLoader.js
@@ -1,6 +1,5 @@
-import { isDesktopElectronRuntime } from '@/lib/electronRuntime';
+const bundledTemplateUrl = `${import.meta.env.BASE_URL}onboarding-example.mnteadlgproj`;
import {
- getOnboardingExampleBundledPath,
getOnboardingExampleProjectRemoteUrl,
} from '@/lib/runtimeConfig';
@@ -25,12 +24,52 @@ function toTemplateFile(blob, source) {
});
}
-async function fetchBlob(url, sourceLabel) {
- const response = await fetch(url, { cache: 'no-store' });
- if (!response.ok) {
- throw new Error(`${sourceLabel} fetch failed with HTTP ${response.status}`);
+const ALLOWED_TEMPLATE_HOSTS = new Set(['github.com', 'raw.githubusercontent.com']);
+
+function assertAllowedTemplateUrl(value, sourceLabel) {
+ let url;
+ try {
+ url = new URL(String(value), globalThis.location?.href);
+ } catch (_error) {
+ url = null;
+ }
+ const sameOrigin = url && globalThis.location && url.origin === globalThis.location.origin;
+ const allowedRemote = url && url.protocol === 'https:' && ALLOWED_TEMPLATE_HOSTS.has(url.hostname);
+ if (!sameOrigin && !allowedRemote) {
+ throw new OnboardingTemplateError(
+ `${sourceLabel} URL is not allowed`,
+ ONBOARDING_TEMPLATE_ERROR_CODES.REMOTE_FETCH_FAILED,
+ { url: String(value) }
+ );
}
- return await response.blob();
+ return url.href;
+}
+
+async function fetchBlob(url, sourceLabel) {
+ const safeUrl = assertAllowedTemplateUrl(url, sourceLabel);
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 10000);
+ try {
+ const response = await fetch(safeUrl, { cache: 'no-store', signal: controller.signal });
+ if (response.url) assertAllowedTemplateUrl(response.url, sourceLabel);
+ if (!response.ok) throw new Error(`${sourceLabel} fetch failed with HTTP ${response.status}`);
+ const limit = 32 * 1024 * 1024;
+ if (Number(response.headers.get('content-length')) > limit) { controller.abort(); throw new Error('Onboarding template size is invalid'); }
+ const reader = response.body?.getReader();
+ if (!reader) throw new Error('Onboarding template response has no body');
+ const chunks = [];
+ let size = 0;
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ size += value.byteLength;
+ if (size > limit) { await reader.cancel(); controller.abort(); throw new Error('Onboarding template size is invalid'); }
+ chunks.push(value);
+ }
+ const blob = new Blob(chunks);
+ if (!blob.size) throw new Error('Onboarding template size is invalid');
+ return blob;
+ } finally { clearTimeout(timeout); }
}
function tryBuildGithubRawUrl(urlString) {
@@ -83,12 +122,6 @@ function buildRemoteTemplateCandidates(remoteUrl) {
export async function resolveOnboardingExampleTemplateFile() {
const remoteUrl = String(getOnboardingExampleProjectRemoteUrl() || '').trim();
- if (!remoteUrl) {
- throw new OnboardingTemplateError(
- 'Onboarding template URL is not configured',
- ONBOARDING_TEMPLATE_ERROR_CODES.REMOTE_FETCH_FAILED
- );
- }
try {
const remoteCandidates = buildRemoteTemplateCandidates(remoteUrl);
@@ -117,16 +150,7 @@ export async function resolveOnboardingExampleTemplateFile() {
remoteUrl: resolvedRemoteUrl,
};
} catch (remoteError) {
- if (!isDesktopElectronRuntime()) {
- throw new OnboardingTemplateError(
- 'Could not download onboarding example. Import a local .mnteadlgproj file to continue.',
- ONBOARDING_TEMPLATE_ERROR_CODES.WEB_FALLBACK_REQUIRED,
- { remoteUrl, cause: remoteError }
- );
- }
-
- const bundledRelativePath = String(getOnboardingExampleBundledPath() || '').trim();
- const bundledUrl = `${import.meta.env.BASE_URL}${bundledRelativePath}`;
+ const bundledUrl = bundledTemplateUrl;
try {
const blob = await fetchBlob(bundledUrl, 'Bundled onboarding template');
return {
diff --git a/src/lib/persistence/canonicalProject.js b/src/lib/persistence/canonicalProject.js
new file mode 100644
index 0000000..1f8ad61
--- /dev/null
+++ b/src/lib/persistence/canonicalProject.js
@@ -0,0 +1,193 @@
+import { AUTHORING_TABLES } from './schema';
+import { normalizeProjectLocalizationConfig, validateLocalizedEntriesForDialogue } from '@/lib/localization/stringTable';
+import { validateCategoryTree, normalizeParticipant, validateDefinition } from '@/lib/domainIntegrity';
+
+export const PROJECT_FORMAT = 'mountea.project.v3';
+export const RECORD_TABLES = AUTHORING_TABLES.filter((table) => table !== 'projects');
+export const START_NODE_ID = '00000000-0000-0000-0000-000000000001';
+export const compoundIdentity = (dialogueId, id) => JSON.stringify([dialogueId, id]);
+const LOCAL_FIELDS = ['lastExportPath', 'syncTimestamp', 'syncRevision', 'lastSyncedAt', 'remoteFileId'];
+export class ProjectDataError extends Error {
+ constructor(code, message, diagnostics = []) { super(message); this.name = 'ProjectDataError'; this.code = code; this.diagnostics = diagnostics; }
+}
+export function recordsToSnapshot(records) { return { version: 3, format: PROJECT_FORMAT, project: records.projects?.[0], ...Object.fromEntries(RECORD_TABLES.map((table) => [table, records[table] || []])) }; }
+export function snapshotToRecords(snapshot) { return { projects: snapshot.project ? [snapshot.project] : [], ...Object.fromEntries(RECORD_TABLES.map((table) => [table, snapshot[table] || []])) }; }
+
+function bytesToBase64(bytes) {
+ let value = '';
+ for (let offset = 0; offset < bytes.length; offset += 32768) value += String.fromCharCode(...bytes.subarray(offset, offset + 32768));
+ return btoa(value);
+}
+export function decodeMediaBase64(value, path = 'audioFile') {
+ if (typeof value !== 'string' || !/^data:[^,]*;base64,[A-Za-z0-9+/]*={0,2}$/.test(value)) throw new ProjectDataError('INVALID_MEDIA', `Invalid base64 media at ${path}.`, [{ path }]);
+ try { return Uint8Array.from(atob(value.slice(value.indexOf(',') + 1)), (character) => character.charCodeAt(0)); }
+ catch { throw new ProjectDataError('INVALID_MEDIA', `Invalid base64 media at ${path}.`, [{ path }]); }
+}
+export async function serializeAudio(audio, path) {
+ if (!audio) return audio;
+ const result = { ...audio };
+ let bytes;
+ if (audio.blob instanceof Blob) bytes = new Uint8Array(await audio.blob.arrayBuffer());
+ else if (audio.base64 || audio.dataUrl) bytes = decodeMediaBase64(audio.base64 || audio.dataUrl, path);
+ else throw new ProjectDataError('MISSING_MEDIA', `Audio bytes are missing at ${path}. Restore the original audio before exporting or synchronizing.`, [{ path }]);
+ if (Number.isFinite(audio.size) && audio.size !== bytes.length) throw new ProjectDataError('MEDIA_SIZE_MISMATCH', `Audio byte length differs from its metadata at ${path}.`, [{ path, expected: audio.size, actual: bytes.length }]);
+ const mimeType = audio.mimeType || audio.blob?.type || audio.type || 'application/octet-stream';
+ delete result.blob; delete result.url; delete result.dataUrl;
+ return { ...result, mimeType, size: bytes.length, base64: `data:${mimeType};base64,${bytesToBase64(bytes)}` };
+}
+function jsonValue(value, path = '', ancestors = new Set()) {
+ if (value === undefined || typeof value === 'function') return undefined;
+ if (value === null || ['string', 'boolean'].includes(typeof value)) return value;
+ if (typeof value === 'number') { if (Number.isFinite(value)) return value; throw new ProjectDataError('INVALID_JSON_VALUE', `Non-finite number at ${path}.`); }
+ if (value instanceof Date) return value.toISOString();
+ if (value instanceof Blob || typeof value !== 'object') throw new ProjectDataError('INVALID_JSON_VALUE', `Unsupported authored value at ${path}.`);
+ if (ancestors.has(value)) throw new ProjectDataError('INVALID_JSON_VALUE', `Cyclic authored value at ${path}.`);
+ ancestors.add(value);
+ const result = Array.isArray(value) ? value.map((entry, index) => jsonValue(entry, `${path}[${index}]`, ancestors) ?? null) : Object.fromEntries(Object.keys(value).sort().flatMap((key) => { const entry = jsonValue(value[key], `${path}.${key}`, ancestors); return entry === undefined ? [] : [[key, entry]]; }));
+ ancestors.delete(value); return result;
+}
+
+/** Produces durable JSON; object URLs and React Flow runtime callbacks never
+ * enter backups/revision hashes. Unknown JSON-compatible authored fields remain. */
+export async function canonicalizeProject(input, { allowIncompleteMedia = false } = {}) {
+ if (!input?.project) throw new ProjectDataError('INVALID_PROJECT', 'A project record is required.');
+ const objectAt = (value, path) => { if (!value || typeof value !== 'object' || Array.isArray(value)) throw new ProjectDataError('INVALID_RECORD', `Expected an object at ${path}.`, [{ path }]); };
+ const recordsAt = (value, path) => { if (value !== undefined && !Array.isArray(value)) throw new ProjectDataError('INVALID_TABLE', `Expected an array at ${path}.`, [{ path }]); for (const [index, row] of (value || []).entries()) objectAt(row, `${path}[${index}]`); };
+ objectAt(input.project, 'project');
+ for (const table of RECORD_TABLES) recordsAt(input[table], table);
+ for (const [index, node] of (input.nodes || []).entries()) {
+ if (node.data !== undefined) objectAt(node.data, `nodes[${index}].data`);
+ recordsAt(node.data?.dialogueRows, `nodes[${index}].data.dialogueRows`);
+ recordsAt(node.data?.decorators, `nodes[${index}].data.decorators`);
+ }
+ for (const [index, edge] of (input.edges || []).entries()) {
+ if (edge.data !== undefined) objectAt(edge.data, `edges[${index}].data`);
+ if (edge.data?.conditions !== undefined) objectAt(edge.data.conditions, `edges[${index}].data.conditions`);
+ recordsAt(edge.data?.conditions?.rules, `edges[${index}].data.conditions.rules`);
+ }
+ // Domain envelope is explicit so credentials/provider tables cannot leak when
+ // a caller accidentally supplies a database dump instead of project records.
+ const snapshot = { version: 3, format: PROJECT_FORMAT, project: { ...input.project } };
+ for (const field of LOCAL_FIELDS) delete snapshot.project[field];
+ snapshot.project.localization = normalizeProjectLocalizationConfig(snapshot.project.localization);
+ for (const table of RECORD_TABLES) {
+ if (input[table] !== undefined && !Array.isArray(input[table])) throw new ProjectDataError('INVALID_TABLE', `${table} must be an array.`, [{ path: table }]);
+ snapshot[table] = (input[table] || []).map((record) => ({ ...record }));
+ }
+ snapshot.dialogues = snapshot.dialogues.map((row) => { const next = { ...row }; for (const field of LOCAL_FIELDS) delete next[field]; return next; });
+ snapshot.participants = await Promise.all(snapshot.participants.map(async (participant) => {
+ if (!participant.thumbnail) return participant;
+ const path = `participants.${participant.id}.thumbnail`, thumbnail = participant.thumbnail;
+ try {
+ if (typeof thumbnail !== 'object' || (!thumbnail.blob && !thumbnail.base64 && !thumbnail.dataUrl)) throw new ProjectDataError('MISSING_MEDIA', `Thumbnail bytes are missing at ${path}. Restore the original image before exporting or synchronizing.`, [{ path }]);
+ const normalized = await serializeAudio({ ...thumbnail, size: thumbnail.size ?? thumbnail.sizeBytes }, path);
+ return { ...participant, thumbnail: normalized };
+ } catch (error) {
+ if (!allowIncompleteMedia || error.code !== 'MISSING_MEDIA') throw error;
+ return participant;
+ }
+ }));
+ snapshot.nodes = await Promise.all(snapshot.nodes.filter((node) => node.type !== 'placeholderNode').map(async (node) => {
+ const data = { ...(node.data || {}) };
+ if (Array.isArray(data.dialogueRows)) data.dialogueRows = await Promise.all(data.dialogueRows.map(async (row, index) => {
+ if (!row.audioFile) return { ...row };
+ try { return { ...row, audioFile: await serializeAudio(row.audioFile, `nodes.${node.id}.data.dialogueRows.${index}.audioFile`) }; }
+ catch (error) {
+ if (!allowIncompleteMedia || error.code !== 'MISSING_MEDIA') throw error;
+ const original = { ...row.audioFile }; delete original.url;
+ return { ...row, audioFile: original };
+ }
+ }));
+ const result = { ...node, data };
+ for (const field of ['selected', 'dragging', 'resizing', 'measured', 'positionAbsolute', 'internals']) delete result[field];
+ return result;
+ }));
+ const originalPlaceholderIds = new Set((input.nodes || []).filter((node) => node.type === 'placeholderNode').map((node) => compoundIdentity(node.dialogueId, node.id)));
+ snapshot.edges = snapshot.edges.filter((edge) => !originalPlaceholderIds.has(compoundIdentity(edge.dialogueId, edge.source)) && !originalPlaceholderIds.has(compoundIdentity(edge.dialogueId, edge.target))).map((edge) => { const result = { ...edge }; delete result.selected; return result; });
+ // IndexedDB returns primary-key order, whereas imported arrays may use any
+ // order. Canonicalize record sets while preserving authored embedded order.
+ for (const table of RECORD_TABLES) {
+ const key = (row) => JSON.stringify(['nodes', 'edges'].includes(table) ? [row.dialogueId, row.id] : [table === 'localizedStrings' ? row.key : row.id]);
+ snapshot[table].sort((left, right) => key(left) < key(right) ? -1 : key(left) > key(right) ? 1 : 0);
+ }
+ return jsonValue(snapshot);
+}
+
+export function validateCanonicalProject(snapshot, { localization = true } = {}) {
+ const diagnostics = [];
+ const issue = (code, path, message) => diagnostics.push({ code, path, message });
+ const projectId = snapshot.project?.id;
+ if (typeof projectId !== 'string' || !projectId) issue('INVALID_ID', 'project.id', 'Project identity must be a nonempty string.');
+ const maps = {};
+ const domainCheck = (path, check) => { try { check(); } catch (error) { issue(error.code || 'INVALID_DOMAIN_RECORD', path, error.message); } };
+ domainCheck('categories', () => validateCategoryTree(snapshot.categories || [], projectId));
+ for (const row of snapshot.participants || []) domainCheck(`participants.${row.id}`, () => normalizeParticipant(row, snapshot, row.id));
+ for (const table of ['decorators', 'conditions']) for (const row of snapshot[table] || []) domainCheck(`${table}.${row.id}`, () => validateDefinition(row));
+ for (const table of RECORD_TABLES) {
+ maps[table] = new Map();
+ for (const [index, row] of (snapshot[table] || []).entries()) {
+ const id = table === 'localizedStrings' ? row.key : row.id;
+ const path = `${table}[${index}]`;
+ if (typeof id !== 'string' || !id) issue('INVALID_ID', path, 'Record identity must be a nonempty string.');
+ const key = ['nodes', 'edges'].includes(table) ? compoundIdentity(row.dialogueId, id) : id;
+ if (maps[table].has(key)) issue('DUPLICATE_ID', path, 'Duplicate record identity.');
+ maps[table].set(key, row);
+ if (!['nodes', 'edges'].includes(table) && row.projectId !== projectId) issue('FOREIGN_OWNERSHIP', `${path}.projectId`, 'Record belongs to a different project.');
+ }
+ }
+ for (const node of snapshot.nodes || []) {
+ const path = `nodes.${node.dialogueId}.${node.id}`, data = node.data || {};
+ if (!maps.dialogues.has(node.dialogueId)) issue('MISSING_DIALOGUE', `${path}.dialogueId`, 'Owning dialogue is missing.');
+ if (data.participantId && !maps.participants.has(data.participantId)) issue('MISSING_PARTICIPANT', `${path}.data.participantId`, 'Participant is missing.');
+ if (data.participant && !data.participantId) issue('UNRESOLVED_PARTICIPANT', `${path}.data.participantId`, 'Resolve the participant name to a stable identity.');
+ if (data.targetNode && !maps.nodes.has(compoundIdentity(node.dialogueId, data.targetNode))) issue('MISSING_TARGET', `${path}.data.targetNode`, 'Return target is missing.');
+ if (data.targetDialogue && !maps.dialogues.has(data.targetDialogue)) issue('MISSING_TARGET', `${path}.data.targetDialogue`, 'Child dialogue is missing.');
+ for (const [index, instance] of (data.decorators || []).entries()) if (!maps.decorators.has(instance.id)) issue('MISSING_DEFINITION', `${path}.data.decorators[${index}]`, 'Decorator definition is missing.');
+ const rows = new Set();
+ for (const [index, row] of (data.dialogueRows || []).entries()) {
+ if (!row.id || rows.has(row.id)) issue('INVALID_ROW_ID', `${path}.data.dialogueRows[${index}].id`, 'Row identities must be unique within a node.');
+ rows.add(row.id);
+ if (row.participantId && !maps.participants.has(row.participantId)) issue('MISSING_PARTICIPANT', `${path}.data.dialogueRows[${index}].participantId`, 'Row participant is missing.');
+ if (row.participant && !row.participantId) issue('UNRESOLVED_PARTICIPANT', `${path}.data.dialogueRows[${index}].participantId`, 'Resolve the row participant name to a stable identity.');
+ }
+ }
+ for (const edge of snapshot.edges || []) {
+ for (const field of ['source', 'target']) if (!maps.nodes.has(compoundIdentity(edge.dialogueId, edge[field]))) issue('MISSING_ENDPOINT', `edges.${edge.id}.${field}`, 'Edge endpoint is missing.');
+ for (const [index, instance] of (edge.data?.conditions?.rules || []).entries()) if (!maps.conditions.has(instance.id)) issue('MISSING_DEFINITION', `edges.${edge.id}.data.conditions.rules[${index}]`, 'Condition definition is missing.');
+ }
+ for (const participant of snapshot.participants || []) {
+ if (participant.categoryId && !maps.categories.has(participant.categoryId)) issue('MISSING_CATEGORY', `participants.${participant.id}.categoryId`, 'Category is missing.');
+ if (participant.category && !participant.categoryId) issue('UNRESOLVED_CATEGORY', `participants.${participant.id}.categoryId`, 'Resolve the category name to a stable identity.');
+ }
+ for (const category of snapshot.categories || []) {
+ let cursor = category; const seen = new Set();
+ while (cursor) {
+ if (seen.has(cursor.id)) { issue('CATEGORY_CYCLE', `categories.${category.id}.parentCategoryId`, 'Category hierarchy contains a cycle.'); break; }
+ seen.add(cursor.id);
+ if (seen.size > 5) { issue('CATEGORY_DEPTH', `categories.${category.id}.parentCategoryId`, 'Category depth exceeds five.'); break; }
+ if (cursor.parentCategoryId && !maps.categories.has(cursor.parentCategoryId)) issue('MISSING_CATEGORY', `categories.${category.id}.parentCategoryId`, 'Parent category is missing.');
+ cursor = maps.categories.get(cursor.parentCategoryId);
+ }
+ }
+ for (const dialogue of snapshot.dialogues || []) {
+ const nodes = (snapshot.nodes || []).filter((node) => node.dialogueId === dialogue.id);
+ const start = nodes.filter((node) => ['start', 'startNode'].includes(node.type));
+ if (start.length !== 1 || start[0]?.id !== START_NODE_ID) issue('INVALID_START', `dialogues.${dialogue.id}`, 'Dialogue must contain exactly one canonical Start node.');
+ if (localization) {
+ const entries = (snapshot.localizedStrings || []).filter((entry) => entry.dialogueId === dialogue.id);
+ const validation = validateLocalizedEntriesForDialogue({ projectId, dialogueId: dialogue.id, dialogueSlug: dialogue.localizationSlug, nodes, entries, projectEntries: snapshot.localizedStrings, defaultLocale: snapshot.project?.localization?.defaultLocale || 'en' });
+ for (const error of validation.errors) issue('LOCALIZATION_REPAIR_REQUIRED', `dialogues.${dialogue.id}.${error.key || error.nodeId || ''}.${error.field || ''}`, error.type);
+ }
+ }
+ return diagnostics;
+}
+export function requireValidProject(snapshot, options) {
+ const diagnostics = validateCanonicalProject(snapshot, options);
+ if (diagnostics.length) throw new ProjectDataError('INVALID_PROJECT_DATA', diagnostics[0].message, diagnostics);
+ return snapshot;
+}
+export const stableJson = (value) => JSON.stringify(jsonValue(value));
+export async function hashProject(snapshot) {
+ const buffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(stableJson(snapshot)));
+ return Array.from(new Uint8Array(buffer), (byte) => byte.toString(16).padStart(2, '0')).join('');
+}
diff --git a/src/lib/persistence/migration.js b/src/lib/persistence/migration.js
new file mode 100644
index 0000000..1b452e2
--- /dev/null
+++ b/src/lib/persistence/migration.js
@@ -0,0 +1,155 @@
+import Dexie from 'dexie';
+import { DATABASE_SCHEMA } from './schema';
+
+const present = (value) => value !== null && value !== undefined && value !== '';
+const identity = (value) => `${typeof value}:${String(value)}`;
+const nodeKey = (dialogueId, id) => `${identity(dialogueId)}|${identity(id)}`;
+const START_ID = '00000000-0000-0000-0000-000000000001';
+
+/** Pure two-pass conversion. The source database remains an untouched backup. */
+export function transformLegacyRecords(source) {
+ const output = Object.fromEntries(Object.keys(DATABASE_SCHEMA).map((name) => [name, []]));
+ const maps = {}, diagnostics = [];
+ const originals = new WeakMap();
+ const diagnose = (table, row, code, path, message, projectId = row.projectId, location = {}) => {
+ diagnostics.push({ id: crypto.randomUUID(), projectId, table, code, path, message, status: 'unresolved', original: structuredClone(originals.get(row) || row), recordKey: ['nodes', 'edges'].includes(table) ? [row.dialogueId, row.id] : row.id, ...location });
+ };
+ for (const table of ['projects', 'dialogues', 'categories', 'participants', 'decorators', 'conditions']) {
+ maps[table] = new Map((source[table] || []).map((row) => [identity(row.id), typeof row.id === 'number' ? crypto.randomUUID() : row.id]));
+ }
+ const remap = (table, id) => maps[table]?.get(identity(id)) ?? (typeof id === 'string' && /^\d+$/.test(id) ? maps[table]?.get(identity(Number(id))) : undefined) ?? id;
+ for (const [table, rows] of Object.entries(source)) {
+ if (!output[table]) {
+ for (const row of rows) diagnose(table, row, 'unknown_table', '', 'This unrecognized table requires recovery using the preserved source database.');
+ continue;
+ }
+ if (['nodes', 'edges', 'migrationState', 'recoveryRecords'].includes(table)) continue;
+ output[table] = rows.map((original) => {
+ const row = structuredClone(original);
+ originals.set(row, original);
+ if (maps[table]) row.id = remap(table, row.id);
+ if (present(row.projectId)) row.projectId = remap('projects', row.projectId);
+ if (present(row.dialogueId)) row.dialogueId = remap('dialogues', row.dialogueId);
+ if (present(row.parentCategoryId)) row.parentCategoryId = remap('categories', row.parentCategoryId);
+ if (present(row.categoryId)) row.categoryId = remap('categories', row.categoryId);
+ if (table === 'syncTombstones') row.entityId = remap(row.entityType === 'project' ? 'projects' : 'dialogues', row.entityId);
+ return row;
+ });
+ }
+ const dialogueProjects = new Map(output.dialogues.map((row) => [row.id, row.projectId]));
+ const categoryPath = (category) => {
+ const names = [], visited = new Set();
+ let cursor = category;
+ while (cursor) {
+ if (visited.has(cursor.id)) return null;
+ visited.add(cursor.id); names.unshift(cursor.name);
+ cursor = output.categories.find((row) => row.id === cursor.parentCategoryId && row.projectId === category.projectId);
+ }
+ return names.join('.');
+ };
+ const resolveNamedReference = (table, row, field, projection, candidates, projectId, original = row, location = {}) => {
+ const path = location.rowId ? `data.dialogueRows.${location.rowId}.${field}` : field;
+ if (present(row[field])) {
+ if (!candidates.some((candidate) => candidate.id === row[field])) diagnose(table, original, 'missing_reference', path, `Referenced ${field} is missing or belongs to another project.`, projectId, location);
+ return;
+ }
+ if (!present(projection)) return;
+ const matches = candidates.filter((candidate) => candidate.name === projection || (table === 'participants' && categoryPath(candidate) === projection));
+ if (matches.length === 1) row[field] = matches[0].id;
+ else diagnose(table, original, matches.length ? 'ambiguous_reference' : 'missing_reference', path, `Choose a replacement for ${field}: ${String(projection)}.`, projectId, location);
+ };
+ for (const [index, row] of output.participants.entries()) resolveNamedReference('participants', row, 'categoryId', row.categoryPath || row.category, output.categories.filter((item) => item.projectId === row.projectId), row.projectId, source.participants[index], { recordKey: row.id });
+ const nodeIds = new Map(), rowIds = new Map();
+ for (const row of source.nodes || []) {
+ nodeIds.set(nodeKey(row.dialogueId, row.id), ['start', 'startNode'].includes(row.type) ? START_ID : typeof row.id === 'number' ? crypto.randomUUID() : row.id);
+ for (const item of row.data?.dialogueRows || []) rowIds.set(`${nodeKey(row.dialogueId, row.id)}|${identity(item.id)}`, typeof item.id === 'number' ? crypto.randomUUID() : item.id);
+ }
+ const nodeId = (dialogueId, id) => nodeIds.get(nodeKey(dialogueId, id)) ?? (typeof id === 'string' && /^\d+$/.test(id) ? nodeIds.get(nodeKey(dialogueId, Number(id))) : undefined) ?? id;
+ for (const original of source.nodes || []) {
+ const row = structuredClone(original);
+ originals.set(row, original);
+ row.id = nodeIds.get(nodeKey(original.dialogueId, original.id));
+ row.dialogueId = remap('dialogues', original.dialogueId);
+ const projectId = dialogueProjects.get(row.dialogueId);
+ if (output.nodes.some((item) => item.id === row.id && item.dialogueId === row.dialogueId)) {
+ diagnose('nodes', original, 'duplicate_identity', 'id', 'Two records map to the same node identity. This original is quarantined for manual recovery; restore it with a distinct identity.', projectId);
+ continue;
+ }
+ row.data ||= {};
+ if (present(row.data.targetNode)) row.data.targetNode = nodeId(original.dialogueId, row.data.targetNode);
+ if (present(row.data.targetDialogue)) row.data.targetDialogue = remap('dialogues', row.data.targetDialogue);
+ if (present(row.data.participantId)) row.data.participantId = remap('participants', row.data.participantId);
+ resolveNamedReference('nodes', row.data, 'participantId', row.data.participant, output.participants.filter((item) => item.projectId === projectId), projectId, original, { recordKey: [row.dialogueId, row.id], dialogueId: row.dialogueId, nodeId: row.id });
+ for (const decorator of row.data.decorators || []) decorator.id = remap('decorators', decorator.id);
+ for (const dialogueRow of row.data.dialogueRows || []) {
+ dialogueRow.id = rowIds.get(`${nodeKey(original.dialogueId, original.id)}|${identity(dialogueRow.id)}`) ?? dialogueRow.id;
+ if (present(dialogueRow.participantId)) dialogueRow.participantId = remap('participants', dialogueRow.participantId);
+ resolveNamedReference('nodes', dialogueRow, 'participantId', dialogueRow.participant, output.participants.filter((item) => item.projectId === projectId), projectId, original, { recordKey: [row.dialogueId, row.id], dialogueId: row.dialogueId, nodeId: row.id, rowId: dialogueRow.id });
+ }
+ output.nodes.push(row);
+ }
+ for (const original of source.edges || []) {
+ const row = structuredClone(original);
+ originals.set(row, original);
+ row.id = typeof row.id === 'number' ? crypto.randomUUID() : row.id;
+ row.dialogueId = remap('dialogues', original.dialogueId);
+ row.source = nodeId(original.dialogueId, original.source);
+ row.target = nodeId(original.dialogueId, original.target);
+ for (const rule of row.data?.conditions?.rules || []) rule.id = remap('conditions', rule.id);
+ output.edges.push(row);
+ }
+ for (let index = 0; index < output.localizedStrings.length; index++) {
+ const original = source.localizedStrings[index];
+ output.localizedStrings[index].nodeId = nodeId(original.dialogueId, original.nodeId);
+ if (present(original.rowId)) output.localizedStrings[index].rowId = rowIds.get(`${nodeKey(original.dialogueId, original.nodeId)}|${identity(original.rowId)}`) ?? original.rowId;
+ }
+ const projects = new Set(output.projects.map((row) => row.id));
+ for (const table of ['dialogues', 'categories', 'participants', 'decorators', 'conditions']) for (const row of output[table]) if (!projects.has(row.projectId)) diagnose(table, row, 'missing_project', 'projectId', 'The owning project is missing.');
+ for (const row of output.categories) {
+ if (!categoryPath(row)) diagnose('categories', row, 'category_cycle', 'parentCategoryId', 'Remove the cycle in the category hierarchy.');
+ else if (categoryPath(row).split('.').length > 5) diagnose('categories', row, 'category_depth', 'parentCategoryId', 'Move this category within the maximum depth of five.');
+ if (present(row.parentCategoryId) && !output.categories.some((item) => item.id === row.parentCategoryId && item.projectId === row.projectId)) diagnose('categories', row, 'missing_reference', 'parentCategoryId', 'The parent category is missing or belongs to another project.');
+ }
+ for (const row of output.nodes) {
+ const projectId = dialogueProjects.get(row.dialogueId);
+ if (!dialogueProjects.has(row.dialogueId)) diagnose('nodes', row, 'missing_dialogue', 'dialogueId', 'The owning dialogue is missing.');
+ if (present(row.data.targetNode) && !output.nodes.some((item) => item.id === row.data.targetNode && item.dialogueId === row.dialogueId)) diagnose('nodes', row, 'missing_reference', 'data.targetNode', 'The return target is missing.', projectId);
+ if (present(row.data.targetDialogue) && dialogueProjects.get(row.data.targetDialogue) !== projectId) diagnose('nodes', row, 'missing_reference', 'data.targetDialogue', 'The child dialogue is missing or belongs to another project.', projectId);
+ for (const decorator of row.data.decorators || []) if (!output.decorators.some((item) => item.id === decorator.id && item.projectId === projectId)) diagnose('nodes', row, 'missing_reference', 'data.decorators', 'A decorator definition is missing or belongs to another project.', projectId);
+ }
+ for (const row of output.edges) for (const rule of row.data?.conditions?.rules || []) if (!output.conditions.some((item) => item.id === rule.id && item.projectId === dialogueProjects.get(row.dialogueId))) diagnose('edges', row, 'missing_reference', 'data.conditions.rules', 'A condition definition is missing or belongs to another project.', dialogueProjects.get(row.dialogueId));
+ for (const row of output.edges) for (const field of ['source', 'target']) if (!output.nodes.some((node) => node.dialogueId === row.dialogueId && node.id === row[field])) diagnose('edges', row, 'missing_reference', field, 'The edge endpoint is missing.', dialogueProjects.get(row.dialogueId));
+ output.recoveryRecords = diagnostics;
+ return { records: output, diagnostics };
+}
+
+export async function migrateLegacyDatabase(target, sourceName, { beforeCommit, beforeActivation } = {}) {
+ const manifestKey = `mountea-database-manifest::${sourceName}`;
+ let completed = await target.migrationState.get('generation2');
+ if (!completed) {
+ let sourceRecords = {}, sourceVersion = null;
+ if (await Dexie.exists(sourceName)) {
+ const source = new Dexie(sourceName); // Dynamic schema: no version declaration or upgrade.
+ try {
+ await source.open(); sourceVersion = source.verno;
+ if (sourceVersion > 9) throw new Error(`Unsupported source database version ${sourceVersion}; use a newer application. The source has not been modified.`);
+ sourceRecords = await source.transaction('r', source.tables, async () => Object.fromEntries(await Promise.all(source.tables.map(async (table) => [table.name, await table.toArray()]))));
+ } finally { source.close(); }
+ }
+ const { records, diagnostics } = transformLegacyRecords(sourceRecords);
+ await target.transaction('rw', target.tables, async () => {
+ completed = await target.migrationState.get('generation2');
+ if (completed) return;
+ for (const [table, rows] of Object.entries(records)) if (rows.length) await target.table(table).bulkAdd(rows);
+ await beforeCommit?.({ records, diagnostics });
+ completed = { key: 'generation2', status: 'complete', sourceName, sourceVersion, targetName: target.name, diagnostics: diagnostics.length, completedAt: new Date().toISOString() };
+ await target.migrationState.put(completed);
+ });
+ }
+ await beforeActivation?.(completed);
+ // Activation is retried after interruption. The transactional target marker
+ // prevents copying again and overwriting newer edits. The manifest alone is
+ // never trusted as evidence that copying completed.
+ globalThis.localStorage?.setItem(manifestKey, JSON.stringify(completed));
+ return completed;
+}
diff --git a/src/lib/persistence/projectArchive.js b/src/lib/persistence/projectArchive.js
new file mode 100644
index 0000000..0289a7c
--- /dev/null
+++ b/src/lib/persistence/projectArchive.js
@@ -0,0 +1,272 @@
+import JSZip from 'jszip';
+import { canonicalizeProject, requireValidProject, RECORD_TABLES, decodeMediaBase64, serializeAudio, ProjectDataError } from './canonicalProject';
+import { remapProjectIdentities } from './projectRemap';
+import { getRepositoryContext } from '@/lib/db';
+import { readProjectState, prepareProjectCommit } from './projectRepository';
+import { ZIP_LOCAL_SIG, ZIP_CENTRAL_SIG, ZIP_EOCD_SIG, CRC32_POLY, CRC32_INIT } from './zipConstants';
+import { parseImportedStringTableData } from '@/lib/localization/stringTable';
+
+export const ARCHIVE_LIMITS = Object.freeze({ compressed: 25 * 1024 * 1024, json: 5 * 1024 * 1024, entries: 1000, dialogues: 250, expanded: 128 * 1024 * 1024 });
+const fail = (code, message, path) => { throw new ProjectDataError(code, message, path ? [{ path }] : []); };
+const decoder = new TextDecoder('utf-8', { fatal: true });
+const encode = (value) => new TextEncoder().encode(JSON.stringify(value));
+const crcTable = Uint32Array.from({ length: 256 }, (_, index) => {
+ let value = index;
+ for (let bit = 0; bit < 8; bit++) value = (value >>> 1) ^ ((value & 1) ? CRC32_POLY : 0);
+ return value >>> 0;
+});
+function safePath(name) {
+ if (typeof name !== 'string' || !name || name.includes('\\') || name.startsWith('/') || name.includes(':') || name.includes('//') || [...name].some((character) => character.charCodeAt(0) < 32) || name.split('/').some((part) => part === '..' || part === '.')) fail('UNSAFE_ARCHIVE_PATH', 'Archive contains an unsafe original entry path.', name);
+ return name.normalize('NFC');
+}
+/** Inspect original central directory names before JSZip can normalize or replace
+ * duplicate entries. ZIP64 and multipart containers are intentionally unsupported. */
+function inspectDirectory(bytes, budget) {
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+ let end = -1;
+ for (let offset = bytes.length - 22; offset >= Math.max(0, bytes.length - 65557); offset--) if (view.getUint32(offset, true) === ZIP_EOCD_SIG && offset + 22 + view.getUint16(offset + 20, true) === bytes.length) { end = offset; break; }
+ if (end < 0) fail('INVALID_ARCHIVE', 'ZIP directory is missing or truncated.');
+ const count = view.getUint16(end + 10, true), size = view.getUint32(end + 12, true), start = view.getUint32(end + 16, true);
+ if (view.getUint16(end + 4, true) || view.getUint16(end + 6, true) || count === 65535 || start + size !== end) fail('UNSUPPORTED_ARCHIVE', 'Multipart and ZIP64 archives are unsupported.');
+ budget.entries += count;
+ if (budget.entries > ARCHIVE_LIMITS.entries) fail('ARCHIVE_ENTRY_LIMIT', 'Archive exceeds the cumulative entry count limit.');
+ const names = new Set(), checksums = new Map(); let cursor = start;
+ for (let index = 0; index < count; index++) {
+ if (cursor + 46 > end || view.getUint32(cursor, true) !== ZIP_CENTRAL_SIG) fail('INVALID_ARCHIVE', 'ZIP directory entry is invalid.');
+ const length = view.getUint16(cursor + 28, true), extra = view.getUint16(cursor + 30, true), comment = view.getUint16(cursor + 32, true);
+ if (cursor + 46 + length + extra + comment > end) fail('INVALID_ARCHIVE', 'ZIP directory entry is truncated.');
+ const name = safePath(decoder.decode(bytes.subarray(cursor + 46, cursor + 46 + length)));
+ // JSZip takes the filename from the local header. Inspect it before the
+ // library can overwrite entries with mismatching effective identities.
+ const local = view.getUint32(cursor + 42, true);
+ if (local + 30 > start || view.getUint32(local, true) !== ZIP_LOCAL_SIG) fail('INVALID_ARCHIVE', 'ZIP local header is invalid.');
+ const localLength = view.getUint16(local + 26, true), localExtra = view.getUint16(local + 28, true);
+ if (local + 30 + localLength + localExtra > start) fail('INVALID_ARCHIVE', 'ZIP local header is truncated.');
+ const localName = safePath(decoder.decode(bytes.subarray(local + 30, local + 30 + localLength)));
+ if (localName !== name) fail('UNSUPPORTED_ARCHIVE_PATH', 'ZIP local and central entry paths must match.', name);
+ // JSZip interprets Unicode path extras before normalizing names. Reject
+ // overrides rather than letting them hide duplicate or traversing paths.
+ let extraCursor = cursor + 46 + length;
+ const extraEnd = extraCursor + extra;
+ while (extraCursor < extraEnd) {
+ if (extraCursor + 4 > extraEnd) fail('INVALID_ARCHIVE', 'Truncated ZIP extra field.');
+ const tag = view.getUint16(extraCursor, true), fieldLength = view.getUint16(extraCursor + 2, true);
+ if (extraCursor + 4 + fieldLength > extraEnd) fail('INVALID_ARCHIVE', 'Truncated ZIP extra field.');
+ if (tag === 0x7075) {
+ if (fieldLength < 5 || safePath(decoder.decode(bytes.subarray(extraCursor + 9, extraCursor + 4 + fieldLength))) !== name) fail('UNSUPPORTED_ARCHIVE_PATH', 'ZIP Unicode path overrides are unsupported.', name);
+ }
+ extraCursor += 4 + fieldLength;
+ }
+ const identity = name.replace(/\/$/, '');
+ if (names.has(identity)) fail('DUPLICATE_ARCHIVE_PATH', 'Archive contains duplicate normalized entry paths.', name);
+ names.add(identity); checksums.set(name, view.getUint32(cursor + 16, true)); cursor += 46 + length + extra + comment;
+ }
+ if (cursor !== end) fail('INVALID_ARCHIVE', 'ZIP directory size does not match its entries.');
+ return checksums;
+}
+async function boundedEntry(entry, budget, expectedCrc) {
+ return new Promise((resolve, reject) => {
+ const chunks = []; let length = 0, stopped = false, crc = CRC32_INIT;
+ const stream = entry.internalStream('uint8array');
+ stream.on('data', (chunk) => {
+ if (stopped) return;
+ length += chunk.length; budget.expanded += chunk.length;
+ if (budget.expanded > ARCHIVE_LIMITS.expanded || (/\.json$/i.test(entry.name) && length > ARCHIVE_LIMITS.json)) {
+ stopped = true; stream.pause(); chunks.length = 0;
+ reject(new ProjectDataError('ARCHIVE_EXPANSION_LIMIT', 'Archive expanded content exceeds its allowed size.', [{ path: entry.name }])); return;
+ }
+ for (const byte of chunk) crc = (crc >>> 8) ^ crcTable[(crc ^ byte) & 0xff];
+ chunks.push(chunk);
+ }).on('error', reject).on('end', () => {
+ if (stopped) return;
+ if (((crc ^ CRC32_INIT) >>> 0) !== expectedCrc) { chunks.length = 0; reject(new ProjectDataError('ARCHIVE_CHECKSUM_MISMATCH', 'Archive entry bytes do not match their checksum.', [{ path: entry.name }])); return; }
+ const bytes = new Uint8Array(length); let offset = 0;
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
+ resolve(bytes);
+ }).resume();
+ });
+}
+async function extract(input, budget) {
+ const bytes = input instanceof Uint8Array ? input : new Uint8Array(await input.arrayBuffer());
+ if (bytes.length > ARCHIVE_LIMITS.compressed) fail('ARCHIVE_COMPRESSED_LIMIT', 'Archive exceeds the compressed size limit.');
+ const checksums = inspectDirectory(bytes, budget);
+ const zip = await JSZip.loadAsync(bytes), entries = new Map();
+ for (const entry of Object.values(zip.files)) if (!entry.dir) {
+ safePath(entry.unsafeOriginalName || entry.name);
+ const name = safePath(entry.name);
+ if (entries.has(name)) fail('DUPLICATE_ARCHIVE_PATH', 'Archive contains duplicate effective entry paths.', name);
+ entries.set(name, await boundedEntry(entry, budget, checksums.get(name)));
+ }
+ return entries;
+}
+export async function readBoundedArchive(input, budget = { entries: 0, expanded: 0 }) { return extract(input, budget); }
+function json(entries, path, fallback) {
+ if (!entries.has(path)) { if (fallback !== undefined) return fallback; fail('MISSING_ARCHIVE_ENTRY', 'Required archive entry is missing.', path); }
+ const bytes = entries.get(path);
+ if (bytes.length > ARCHIVE_LIMITS.json) fail('ARCHIVE_JSON_LIMIT', 'JSON entry exceeds its size limit.', path);
+ try { return JSON.parse(decoder.decode(bytes)); } catch { fail('INVALID_ARCHIVE_JSON', 'Archive contains invalid JSON.', path); }
+}
+const empty = (project) => ({ project, ...Object.fromEntries(RECORD_TABLES.map((table) => [table, []])) });
+const filename = (name, id) => `${String(name || 'record').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 70)}--${encodeURIComponent(id)}`;
+
+export async function exportProjectArchive(input, { kind = 'project', dialogueId } = {}) {
+ const snapshot = requireValidProject(await canonicalizeProject(input));
+ if (snapshot.dialogues.length > ARCHIVE_LIMITS.dialogues) fail('ARCHIVE_DIALOGUE_LIMIT', 'Project has too many dialogues for one backup.');
+ const zip = new JSZip(), manifest = { format: 'mountea.archive.v3', kind, dialogueId, project: 'project.json', records: [], media: [] };
+ let expanded = 0, count = 0;
+ const add = (path, bytes) => {
+ if (path.endsWith('.json') && bytes.length > ARCHIVE_LIMITS.json) fail('ARCHIVE_JSON_LIMIT', 'A record exceeds the JSON entry limit.', path);
+ expanded += bytes.length; count++;
+ if (expanded > ARCHIVE_LIMITS.expanded || count > ARCHIVE_LIMITS.entries) fail('ARCHIVE_EXPANSION_LIMIT', 'Project exceeds the restorable archive limits.');
+ zip.file(path, bytes, { createFolders: false });
+ };
+ for (const node of snapshot.nodes) for (const row of node.data?.dialogueRows || []) if (row.audioFile) {
+ const path = `media/${filename(row.audioFile.name, JSON.stringify([node.dialogueId, node.id, row.id]))}`;
+ add(path, decodeMediaBase64(row.audioFile.base64)); delete row.audioFile.base64;
+ manifest.media.push({ path, dialogueId: node.dialogueId, nodeId: node.id, rowId: row.id });
+ }
+ for (const participant of snapshot.participants) if (participant.thumbnail?.base64) {
+ const path = `media/thumbnails/${encodeURIComponent(participant.id)}`;
+ add(path, decodeMediaBase64(participant.thumbnail.base64)); delete participant.thumbnail.base64;
+ manifest.media.push({ path, participantId: participant.id });
+ }
+ add(manifest.project, encode(snapshot.project));
+ for (const table of RECORD_TABLES) for (const [index, record] of snapshot[table].entries()) {
+ const path = `${table}/${filename(record.name || record.key || table, `${record.dialogueId || ''}:${record.id || record.key}:${index}`)}.json`;
+ add(path, encode(record)); manifest.records.push({ table, path });
+ }
+ add('manifest.json', encode(manifest));
+ const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
+ if (bytes.length > ARCHIVE_LIMITS.compressed) fail('ARCHIVE_COMPRESSED_LIMIT', 'Backup exceeds the supported compressed size limit.');
+ return new Blob([bytes], { type: 'application/zip' });
+}
+
+async function parseVersioned(entries, manifest) {
+ if (manifest.format !== 'mountea.archive.v3') fail('UNSUPPORTED_ARCHIVE', 'Unsupported archive format.');
+ if (!['project', 'dialogue'].includes(manifest.kind)) fail('INVALID_ARCHIVE_MANIFEST', 'Archive kind must be project or dialogue.');
+ if (!Array.isArray(manifest.records) || !Array.isArray(manifest.media)) fail('INVALID_ARCHIVE_MANIFEST', 'Archive manifest lists are invalid.');
+ const snapshot = empty(json(entries, manifest.project)); const used = new Set(['manifest.json', manifest.project]);
+ for (const item of manifest.records) {
+ if (!RECORD_TABLES.includes(item.table) || used.has(item.path)) fail('INVALID_ARCHIVE_MANIFEST', 'Duplicate or unsupported manifest record.', item.path);
+ used.add(item.path); snapshot[item.table].push(json(entries, item.path));
+ }
+ const bindings = new Set();
+ for (const item of manifest.media) {
+ const binding = JSON.stringify(item.participantId ? ['thumbnail', item.participantId] : ['audio', item.dialogueId, item.nodeId, item.rowId]);
+ if (bindings.has(binding)) fail('INVALID_ARCHIVE_MEDIA', 'Multiple media entries claim the same record.', item.path);
+ bindings.add(binding);
+ if (item.participantId) {
+ const participant = snapshot.participants.find((row) => row.id === item.participantId);
+ if (!participant?.thumbnail || used.has(item.path) || !entries.has(item.path)) fail('INVALID_ARCHIVE_MEDIA', 'Thumbnail manifest reference is missing or duplicated.', item.path);
+ used.add(item.path);
+ const media = await serializeAudio({ ...participant.thumbnail, blob: new Blob([entries.get(item.path)], { type: participant.thumbnail.mimeType }) }, item.path);
+ participant.thumbnail.base64 = media.base64;
+ continue;
+ }
+ const row = snapshot.nodes.find((node) => node.dialogueId === item.dialogueId && node.id === item.nodeId)?.data?.dialogueRows?.find((candidate) => candidate.id === item.rowId);
+ if (!row?.audioFile || used.has(item.path) || !entries.has(item.path)) fail('INVALID_ARCHIVE_MEDIA', 'Media manifest reference is missing or duplicated.', item.path);
+ used.add(item.path); row.audioFile = await serializeAudio({ ...row.audioFile, blob: new Blob([entries.get(item.path)], { type: row.audioFile.mimeType }) }, item.path);
+ }
+ if (used.size !== entries.size) fail('INVALID_ARCHIVE_MANIFEST', 'Archive has entries not declared by its manifest.');
+ if (manifest.kind === 'dialogue' && !snapshot.dialogues.some((row) => row.id === manifest.dialogueId)) fail('INVALID_ARCHIVE_MANIFEST', 'The archive root dialogue is missing.');
+ return { snapshot, kind: manifest.kind, dialogueId: manifest.dialogueId };
+}
+
+function uniqueByName(records, name, path) {
+ const matches = records.filter((row) => row.name === name);
+ if (matches.length !== 1) fail('LEGACY_REFERENCE_REPAIR_REQUIRED', 'Legacy reference cannot be resolved uniquely.', path);
+ return matches[0].id;
+}
+async function legacySupport(entries, snapshot) {
+ const projectId = snapshot.project.id, paths = new Map();
+ const category = (path) => {
+ let parentCategoryId = null, full = '';
+ for (const name of String(path || '').split('.').filter(Boolean)) {
+ full = full ? `${full}.${name}` : name;
+ let row = paths.get(full);
+ if (!row) { row = { id: crypto.randomUUID(), projectId, name, parentCategoryId }; paths.set(full, row); snapshot.categories.push(row); }
+ parentCategoryId = row.id;
+ }
+ return parentCategoryId;
+ };
+ for (const record of json(entries, 'categories.json', [])) category(record.fullPath || record.name);
+ for (const record of json(entries, 'participants.json', [])) {
+ const row = { ...record, id: record.id || crypto.randomUUID(), projectId, categoryId: category(record.fullPath || record.category), category: String(record.fullPath || record.category || '').split('.').pop() };
+ if (record.participantImage) {
+ const path = `Thumbnails/${record.participantImage}.png`;
+ if (entries.has(path)) { const audio = await serializeAudio({ blob: new Blob([entries.get(path)], { type: 'image/png' }) }, path); row.thumbnail = { dataUrl: audio.base64, mimeType: 'image/png', size: audio.size }; }
+ }
+ snapshot.participants.push(row);
+ }
+ for (const table of ['decorators', 'conditions']) for (const row of json(entries, `${table}.json`, [])) snapshot[table].push({ ...row, id: row.id || crypto.randomUUID(), projectId, properties: row.properties || [] });
+}
+async function legacyDialogue(entries, snapshot) {
+ const metadata = json(entries, 'dialogueData.json'), id = metadata.dialogueGuid || metadata.id;
+ if (!id) fail('INVALID_ID', 'Legacy dialogue has no identity.');
+ const dialogue = { ...metadata, id, projectId: snapshot.project.id, name: metadata.dialogueName || metadata.name, createdAt: metadata.createdAt, modifiedAt: metadata.modifiedOnDate || metadata.modifiedAt };
+ snapshot.dialogues.push(dialogue);
+ const exportedRows = json(entries, 'dialogueRows.json', []);
+ for (const node of json(entries, 'nodes.json')) {
+ node.dialogueId = id; node.data ||= {};
+ if (node.data.participant && !node.data.participantId) node.data.participantId = uniqueByName(snapshot.participants, node.data.participant.name || node.data.participant, `nodes.${node.id}.participant`);
+ for (const instance of node.data.decorators || []) if (!snapshot.decorators.some((definition) => definition.id === instance.id)) instance.id = uniqueByName(snapshot.decorators, instance.name, `nodes.${node.id}.decorators`);
+ for (const row of node.data.dialogueRows || []) {
+ row.id ||= crypto.randomUUID();
+ const files = [...entries.keys()].filter((path) => path.startsWith(`audio/${row.id}/`) && path.split('/').length === 3);
+ if (files.length > 1) fail('AMBIGUOUS_MEDIA', 'Multiple audio files claim the same legacy row.', row.id);
+ if (files.length) row.audioFile = await serializeAudio({ name: files[0].split('/').pop(), blob: new Blob([entries.get(files[0])]) }, files[0]);
+ else if (exportedRows.some((entry) => entry.id === row.id && entry.audioPath)) fail('MISSING_MEDIA', 'Legacy row refers to absent audio.', row.id);
+ }
+ snapshot.nodes.push(node);
+ }
+ snapshot.edges.push(...json(entries, 'edges.json').map((edge) => ({ ...edge, dialogueId: id })));
+ snapshot.localizedStrings.push(...parseImportedStringTableData(json(entries, 'stringTable.json', null)).map((row) => ({ ...row, projectId: snapshot.project.id, dialogueId: id })));
+}
+
+export async function parseProjectArchive(input) {
+ const budget = { entries: 0, expanded: 0 }, entries = await extract(input, budget);
+ let parsed;
+ if (entries.has('manifest.json')) parsed = await parseVersioned(entries, json(entries, 'manifest.json'));
+ else {
+ const metadata = json(entries, 'projectData.json', null), project = metadata ? { ...metadata, id: metadata.projectGuid || metadata.id || crypto.randomUUID(), name: metadata.projectName || metadata.name, description: metadata.projectDescription || metadata.description || '' } : { id: crypto.randomUUID(), name: 'Imported dialogue' };
+ const snapshot = empty(project); await legacySupport(entries, snapshot);
+ if (metadata) {
+ const children = [...entries].filter(([path]) => path.startsWith('dialogues/') && path.endsWith('.mnteadlg'));
+ if (children.length > ARCHIVE_LIMITS.dialogues) fail('ARCHIVE_DIALOGUE_LIMIT', 'Archive has too many dialogues.');
+ for (const [, bytes] of children) { const child = await extract(bytes, budget); await legacyDialogue(child, snapshot); }
+ } else await legacyDialogue(entries, snapshot);
+ parsed = { snapshot: remapProjectIdentities(snapshot, { projectId: project.id, preserveEntityIds: true }).snapshot, kind: metadata ? 'project' : 'dialogue', dialogueId: snapshot.dialogues[0]?.id };
+ }
+ if (parsed.snapshot.dialogues.length > ARCHIVE_LIMITS.dialogues) fail('ARCHIVE_DIALOGUE_LIMIT', 'Archive has too many dialogues.');
+ parsed.snapshot = requireValidProject(await canonicalizeProject(parsed.snapshot));
+ return { ...parsed, diagnostics: [], expandedBytes: budget.expanded };
+}
+
+/** Pure preparation with respect to authoring data; only commitPreparedProject writes. */
+export async function prepareArchiveImport(input, { context: suppliedContext, mode = 'copy', projectId, dialogueId, isExample } = {}) {
+ const context = suppliedContext || await getRepositoryContext();
+ if (!['copy', 'replace'].includes(mode)) fail('INVALID_IMPORT_MODE', 'Choose copy or explicit replacement.');
+ const destination = projectId ? await readProjectState(projectId, context) : null;
+ const parsed = await parseProjectArchive(input); context.assertCurrent();
+ let snapshot, importedRootId, expectedSequence = 0, operation = 'create';
+ if (parsed.kind === 'dialogue') {
+ if (!destination?.snapshot.project) fail('PROJECT_NOT_FOUND', 'Choose a destination project for this dialogue.');
+ if (mode === 'replace' && (!dialogueId || !destination.snapshot.dialogues.some((row) => row.id === dialogueId))) fail('INVALID_REPLACEMENT_TARGET', 'Explicit replacement requires a dialogue in the destination project.');
+ const replacements = mode === 'replace' ? new Map([[parsed.dialogueId, dialogueId]]) : new Map();
+ const imported = remapProjectIdentities(parsed.snapshot, { projectId, dialogueIds: replacements, existingDialogues: destination.snapshot.dialogues.filter((row) => row.id !== dialogueId) });
+ importedRootId = imported.maps.dialogues.get(parsed.dialogueId);
+ snapshot = structuredClone(destination.snapshot);
+ for (const table of RECORD_TABLES) {
+ if (mode === 'replace' && ['dialogues', 'nodes', 'edges', 'localizedStrings'].includes(table)) snapshot[table] = snapshot[table].filter((row) => (table === 'dialogues' ? row.id : row.dialogueId) !== dialogueId);
+ snapshot[table].push(...imported.snapshot[table]);
+ }
+ expectedSequence = destination.sequence; operation = 'update';
+ } else if (mode === 'replace') {
+ if (!destination?.snapshot.project) fail('INVALID_REPLACEMENT_TARGET', 'Explicit replacement requires an existing destination project.');
+ snapshot = remapProjectIdentities(parsed.snapshot, { projectId }).snapshot;
+ expectedSequence = destination.sequence; operation = 'update';
+ } else snapshot = remapProjectIdentities(parsed.snapshot).snapshot;
+ if (isExample !== undefined) snapshot.project.isExample = Boolean(isExample);
+ const prepared = await prepareProjectCommit(snapshot, { context, expectedSequence, operation });
+ return { prepared, diagnostics: parsed.diagnostics, projectId: snapshot.project.id, firstDialogueId: importedRootId || snapshot.dialogues[0]?.id };
+}
diff --git a/src/lib/persistence/projectRemap.js b/src/lib/persistence/projectRemap.js
new file mode 100644
index 0000000..db69e3a
--- /dev/null
+++ b/src/lib/persistence/projectRemap.js
@@ -0,0 +1,76 @@
+import { RECORD_TABLES, START_NODE_ID, compoundIdentity, ProjectDataError } from './canonicalProject';
+import { allocateDialogueLocalizationSlug, prepareLocalizedNodesAndEntries } from '@/lib/localization/stringTable';
+
+/** Allocate every identity before applying references, including forward edges,
+ * return targets, child graphs, definitions, row audio and localization scopes. */
+export function remapProjectIdentities(input, { projectId = crypto.randomUUID(), dialogueIds = new Map(), existingDialogues = [], preserveEntityIds = false } = {}) {
+ const snapshot = structuredClone(input), maps = {};
+ for (const table of ['dialogues', 'categories', 'participants', 'decorators', 'conditions']) maps[table] = new Map((input[table] || []).map((row) => [row.id, table === 'dialogues' && dialogueIds.has(row.id) ? dialogueIds.get(row.id) : preserveEntityIds ? row.id : crypto.randomUUID()]));
+ maps.nodes = new Map(); maps.rows = new Map();
+ for (const node of input.nodes || []) {
+ maps.nodes.set(compoundIdentity(node.dialogueId, node.id), ['start', 'startNode'].includes(node.type) ? START_NODE_ID : preserveEntityIds ? node.id : crypto.randomUUID());
+ for (const row of node.data?.dialogueRows || []) maps.rows.set(JSON.stringify([node.dialogueId, node.id, row.id]), preserveEntityIds ? row.id : crypto.randomUUID());
+ }
+ const remap = (table, id) => maps[table]?.get(id) ?? id;
+ const remapNode = (dialogueId, id) => {
+ const exact = maps.nodes.get(compoundIdentity(dialogueId, id));
+ if (exact) return exact;
+ // Legacy UUID casing may differ on forward references. Never use a
+ // case-insensitive match when more than one identity could match.
+ const matches = (input.nodes || []).filter((node) => node.dialogueId === dialogueId && String(node.id).toLowerCase() === String(id).toLowerCase());
+ if (matches.length > 1) throw new ProjectDataError('AMBIGUOUS_NODE_ID', 'A case-insensitive legacy node reference has multiple matches.');
+ return matches.length === 1 ? maps.nodes.get(compoundIdentity(dialogueId, matches[0].id)) : id;
+ };
+ snapshot.project.id = projectId;
+ for (const table of RECORD_TABLES) for (const row of snapshot[table] || []) {
+ if (table !== 'nodes' && table !== 'edges') row.projectId = projectId;
+ if (maps[table] && table !== 'nodes') row.id = remap(table, row.id);
+ }
+ for (const row of snapshot.categories || []) if (row.parentCategoryId) row.parentCategoryId = remap('categories', row.parentCategoryId);
+ for (const row of snapshot.participants || []) if (row.categoryId) row.categoryId = remap('categories', row.categoryId);
+ for (const node of snapshot.nodes || []) {
+ const oldDialogue = node.dialogueId, oldId = node.id;
+ node.id = remapNode(oldDialogue, oldId); node.dialogueId = remap('dialogues', oldDialogue);
+ const data = node.data || {};
+ if (data.participantId) data.participantId = remap('participants', data.participantId);
+ if (data.targetNode) data.targetNode = remapNode(oldDialogue, data.targetNode);
+ if (data.targetDialogue) data.targetDialogue = remap('dialogues', data.targetDialogue);
+ for (const instance of data.decorators || []) instance.id = remap('decorators', instance.id);
+ for (const row of data.dialogueRows || []) {
+ row.id = maps.rows.get(JSON.stringify([oldDialogue, oldId, row.id])) ?? row.id;
+ if (row.participantId) row.participantId = remap('participants', row.participantId);
+ }
+ }
+ for (const edge of snapshot.edges || []) {
+ const oldDialogue = edge.dialogueId;
+ edge.id = preserveEntityIds ? edge.id : crypto.randomUUID(); edge.dialogueId = remap('dialogues', oldDialogue);
+ edge.source = remapNode(oldDialogue, edge.source); edge.target = remapNode(oldDialogue, edge.target);
+ for (const rule of edge.data?.conditions?.rules || []) rule.id = remap('conditions', rule.id);
+ }
+ for (const entry of snapshot.localizedStrings || []) {
+ const oldDialogue = entry.dialogueId, oldNode = entry.nodeId;
+ entry.dialogueId = remap('dialogues', oldDialogue); entry.nodeId = remapNode(oldDialogue, oldNode);
+ if (entry.rowId) entry.rowId = maps.rows.get(JSON.stringify([oldDialogue, oldNode, entry.rowId])) ?? entry.rowId;
+ }
+ const allocated = [...existingDialogues];
+ for (const dialogue of snapshot.dialogues || []) {
+ const oldSlug = dialogue.localizationSlug;
+ dialogue.localizationSlug = allocateDialogueLocalizationSlug(dialogue, allocated); allocated.push(dialogue);
+ const nodes = snapshot.nodes.filter((node) => node.dialogueId === dialogue.id);
+ const existingEntries = snapshot.localizedStrings.filter((entry) => entry.dialogueId === dialogue.id);
+ const prepared = prepareLocalizedNodesAndEntries({ projectId, dialogueId: dialogue.id, dialogueSlug: dialogue.localizationSlug, nodes, locale: snapshot.project.localization?.defaultLocale || 'en', existingEntries });
+ snapshot.nodes = snapshot.nodes.filter((node) => node.dialogueId !== dialogue.id).concat(prepared.nodes);
+ // Keep unreferenced authored translation entries too: complete backups must
+ // not prune them as a side effect of cloning.
+ const referencedOldKeys = new Set(nodes.flatMap((node) => [node.data?.displayNameKey, node.data?.selectionTitleKey, ...(node.data?.dialogueRows || []).map((row) => row.textKey)]).filter(Boolean));
+ const preparedEntries = prepared.entries.map((entry) => ({ ...existingEntries.find((original) => original.nodeId === entry.nodeId && (original.rowId || '') === (entry.rowId || '') && original.field === entry.field), ...entry }));
+ const unusedEntries = existingEntries.filter((entry) => !referencedOldKeys.has(entry.key) && !prepared.entries.some((newEntry) => newEntry.key === entry.key)).map((entry) => {
+ const oldNamespace = `dlg.${oldSlug || entry.dialogueSlug}.`;
+ const key = entry.key.startsWith(oldNamespace) ? `dlg.${dialogue.localizationSlug}.${entry.key.slice(oldNamespace.length)}` : preserveEntityIds ? entry.key : `dlg.${dialogue.localizationSlug}.unused_${crypto.randomUUID().replaceAll('-', '_')}`;
+ return { ...entry, key, dialogueSlug: dialogue.localizationSlug };
+ });
+ snapshot.localizedStrings = snapshot.localizedStrings.filter((entry) => entry.dialogueId !== dialogue.id).concat(preparedEntries, unusedEntries);
+ dialogue.localizationVersion = 2;
+ }
+ return { snapshot, maps };
+}
diff --git a/src/lib/persistence/projectRepository.js b/src/lib/persistence/projectRepository.js
new file mode 100644
index 0000000..5adf058
--- /dev/null
+++ b/src/lib/persistence/projectRepository.js
@@ -0,0 +1,133 @@
+import { getRepositoryContext, readProjectRecords } from '@/lib/db';
+import { AUTHORING_TABLES } from './schema';
+import { canonicalizeProject, recordsToSnapshot, snapshotToRecords, validateCanonicalProject, hashProject, stableJson, ProjectDataError } from './canonicalProject';
+
+const COMMIT_TABLES = [...AUTHORING_TABLES, 'projectState', 'projectRevisions', 'syncOutbox', 'recoveryRecords'];
+const preparedCommits = new WeakSet();
+const mutationQueues = new WeakMap();
+function deviceId() {
+ const key = 'mountea-revision-device-id';
+ let value = localStorage.getItem(key);
+ if (!value) { value = crypto.randomUUID(); localStorage.setItem(key, value); }
+ return value;
+}
+function freeze(value) {
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.freeze(value); for (const child of Object.values(value)) freeze(child); }
+ return value;
+}
+
+export async function readProjectState(projectId, suppliedContext) {
+ const context = suppliedContext || await getRepositoryContext(); context.assertCurrent();
+ const result = await context.db.transaction('r', [...AUTHORING_TABLES, 'projectState'].map((table) => context.db.table(table)), async () => {
+ const records = await readProjectRecords(projectId, context);
+ const state = await context.db.projectState.get(projectId);
+ return { snapshot: recordsToSnapshot(records), sequence: state?.sequence || 0, revisionId: state?.revisionId || null, state };
+ });
+ context.assertCurrent(); return result;
+}
+
+/** All async media conversion/hashing happens here, outside IndexedDB. */
+export async function prepareProjectCommit(input, { context: suppliedContext, expectedSequence, operation = 'update', parentRevisionIds, revisionId = crypto.randomUUID(), validation = 'strict', existingDiagnostics = [], enqueue = true, recoveryResolutions = [], remoteRevision } = {}) {
+ const context = suppliedContext || await getRepositoryContext(); context.assertCurrent();
+ if (remoteRevision) {
+ if (enqueue) throw new ProjectDataError('INVALID_REMOTE_REVISION', 'Remote revisions cannot enqueue a local edit.');
+ operation = remoteRevision.operation; revisionId = remoteRevision.id; parentRevisionIds = remoteRevision.parentRevisionIds;
+ }
+ if (!Number.isInteger(expectedSequence) || expectedSequence < 0) throw new ProjectDataError('EXPECTED_SEQUENCE_REQUIRED', 'A captured nonnegative project sequence is required.');
+ if (!['update', 'create', 'delete', 'restore', 'resolve'].includes(operation)) throw new ProjectDataError('INVALID_OPERATION', 'Unsupported project operation.');
+ const snapshot = await canonicalizeProject(input, { allowIncompleteMedia: validation === 'draft' });
+ const diagnostics = validateCanonicalProject(snapshot, { localization: validation !== 'draft' });
+ const known = new Set(existingDiagnostics.map((item) => `${item.code}:${item.path}`));
+ const errors = validation === 'draft'
+ ? diagnostics.filter((item) => !['INVALID_START', 'UNRESOLVED_PARTICIPANT', 'UNRESOLVED_CATEGORY'].includes(item.code) && !known.has(`${item.code}:${item.path}`))
+ : diagnostics;
+ if (errors.length && operation !== 'delete') throw new ProjectDataError('INVALID_PROJECT_DATA', errors[0].message, errors);
+ const projectId = snapshot.project.id;
+ const payloadHash = await hashProject(snapshot);
+ const current = await context.db.projectState.get(projectId); context.assertCurrent();
+ const parents = parentRevisionIds ?? (current?.revisionId ? [current.revisionId] : []);
+ if (!Array.isArray(parents) || parents.some((id) => typeof id !== 'string' || !id)) throw new ProjectDataError('INVALID_REVISION_PARENTS', 'Revision parents must be nonempty identifiers.');
+ if (remoteRevision && (remoteRevision.projectId !== projectId || remoteRevision.payloadHash !== payloadHash || typeof revisionId !== 'string' || !revisionId || typeof remoteRevision.deviceId !== 'string' || !remoteRevision.deviceId || typeof remoteRevision.createdAt !== 'string' || !Number.isFinite(Date.parse(remoteRevision.createdAt)))) throw new ProjectDataError('INVALID_REMOTE_REVISION', 'Remote revision identity, metadata or payload hash is invalid.');
+ const revision = freeze(remoteRevision ? { ...structuredClone(remoteRevision), snapshot } : { id: revisionId, projectId, parentRevisionIds: [...new Set(parents)], deviceId: deviceId(), payloadHash, operation, snapshot, createdAt: new Date().toISOString() });
+ const prepared = Object.freeze({ context, projectId, expectedSequence, revision, records: freeze(snapshotToRecords(snapshot)), enqueue, recoveryResolutions: freeze(structuredClone(recoveryResolutions)) });
+ preparedCommits.add(prepared); return prepared;
+}
+
+async function assertOwnership(database, prepared) {
+ for (const table of ['dialogues', 'participants', 'categories', 'decorators', 'conditions']) {
+ const ids = prepared.records[table].map((row) => row.id);
+ for (const record of await database[table].bulkGet(ids)) if (record && record.projectId !== prepared.projectId) throw new ProjectDataError('FOREIGN_OWNERSHIP', `An imported ${table} identity already belongs to another project.`, [{ table, id: record.id, projectId: record.projectId }]);
+ }
+}
+
+export async function commitPreparedProject(prepared) {
+ if (!preparedCommits.has(prepared)) throw new ProjectDataError('INVALID_PREPARED_COMMIT', 'Prepare this project in the current session before committing.');
+ const { context, projectId, expectedSequence, revision } = prepared;
+ context.assertCurrent();
+ const result = await context.db.transaction('rw', COMMIT_TABLES.map((table) => context.db.table(table)), async () => {
+ context.assertCurrent();
+ const current = await context.db.projectState.get(projectId);
+ if ((current?.sequence || 0) !== expectedSequence) throw new ProjectDataError('STALE_PROJECT', 'This project changed while the operation was being prepared. Reload and retry.');
+ if (revision.operation === 'create' && await context.db.projects.get(projectId)) throw new ProjectDataError('PROJECT_EXISTS', 'The destination project already exists. Choose copy or explicit replacement.');
+ await assertOwnership(context.db, prepared);
+ for (const resolution of prepared.recoveryResolutions) {
+ const diagnostic = await context.db.recoveryRecords.get(resolution.id);
+ if (!diagnostic || diagnostic.projectId !== projectId) throw new ProjectDataError('FOREIGN_RECOVERY_RECORD', 'Repair diagnostic does not belong to this project.');
+ await context.db.recoveryRecords.update(resolution.id, { status: 'resolved', restoredKey: resolution.restoredKey });
+ }
+ const previousProject = await context.db.projects.get(projectId);
+ const previousDialogues = new Map((await context.db.dialogues.where('projectId').equals(projectId).toArray()).map((dialogue) => [dialogue.id, dialogue]));
+ const dialogues = await context.db.dialogues.where('projectId').equals(projectId).primaryKeys();
+ for (const table of ['nodes', 'edges']) if (dialogues.length) await context.db[table].where('dialogueId').anyOf(dialogues).delete();
+ for (const table of AUTHORING_TABLES.filter((name) => !['projects', 'nodes', 'edges'].includes(name))) await context.db[table].where('projectId').equals(projectId).delete();
+ await context.db.projects.delete(projectId);
+ if (revision.operation !== 'delete') for (const table of AUTHORING_TABLES) if (prepared.records[table].length) {
+ const records = prepared.records[table].map((record) => {
+ const previous = table === 'projects' ? previousProject : table === 'dialogues' ? previousDialogues.get(record.id) : null;
+ return previous?.lastExportPath ? { ...record, lastExportPath: previous.lastExportPath } : record;
+ });
+ await context.db[table].bulkAdd(records);
+ }
+ const existingRevision = await context.db.projectRevisions.get(revision.id);
+ if (existingRevision) {
+ if (stableJson(existingRevision) !== stableJson(revision)) throw new ProjectDataError('REVISION_ID_COLLISION', 'An immutable revision identity already has different content.');
+ } else await context.db.projectRevisions.add(revision);
+ const sequence = expectedSequence + 1;
+ await context.db.projectState.put({ projectId, sequence, revisionId: revision.id, modifiedAt: revision.createdAt, deleted: revision.operation === 'delete' });
+ // Intent is provider-independent and durable even while offline/disconnected.
+ // Per-provider delivery records acknowledge only this immutable revision.
+ if (prepared.enqueue) await context.db.syncOutbox.add({ id: revision.id, projectId, localRevisionId: revision.id, payloadHash: revision.payloadHash, status: 'pending', operation: revision.operation, createdAt: revision.createdAt });
+ context.assertCurrent(); return { projectId, revisionId: revision.id, sequence, snapshot: revision.snapshot };
+ });
+ preparedCommits.delete(prepared); context.assertCurrent(); return result;
+}
+
+export async function applyRemoteRevision(revision, { context: suppliedContext, expectedSequence } = {}) {
+ const context = suppliedContext || await getRepositoryContext();
+ const current = await readProjectState(revision?.projectId, context);
+ const prepared = await prepareProjectCommit(revision?.snapshot, { context, expectedSequence: expectedSequence ?? current.sequence, enqueue: false, remoteRevision: revision });
+ return commitPreparedProject(prepared);
+}
+
+export async function mutateProject(projectId, { context: suppliedContext, transform, operation = 'update', parentRevisionIds, recoveryResolutions } = {}) {
+ const context = suppliedContext || await getRepositoryContext();
+ context.assertCurrent();
+ let queues = mutationQueues.get(context.db);
+ if (!queues) { queues = new Map(); mutationQueues.set(context.db, queues); }
+ const previous = queues.get(projectId) || Promise.resolve();
+ const perform = async () => {
+ context.assertCurrent();
+ const current = await readProjectState(projectId, context);
+ if (!current.snapshot.project) throw new ProjectDataError('PROJECT_NOT_FOUND', 'Project not found.');
+ const snapshot = structuredClone(current.snapshot);
+ const transformed = transform ? await transform(snapshot) : snapshot;
+ context.assertCurrent();
+ const prepared = await prepareProjectCommit(transformed ?? snapshot, { context, expectedSequence: current.sequence, operation, parentRevisionIds, recoveryResolutions, validation: 'draft', existingDiagnostics: validateCanonicalProject(current.snapshot, { localization: false }) });
+ return commitPreparedProject(prepared);
+ };
+ const work = previous.catch(() => {}).then(() => navigator.locks?.request
+ ? navigator.locks.request(`mountea-project:${context.db.name}:${projectId}`, { signal: context.signal }, perform)
+ : perform());
+ queues.set(projectId, work);
+ try { return await work; } finally { if (queues.get(projectId) === work) queues.delete(projectId); }
+}
diff --git a/src/lib/persistence/schema.js b/src/lib/persistence/schema.js
new file mode 100644
index 0000000..50b0d8d
--- /dev/null
+++ b/src/lib/persistence/schema.js
@@ -0,0 +1,24 @@
+export const AUTHORING_TABLES = ['projects', 'dialogues', 'participants', 'categories', 'decorators', 'conditions', 'nodes', 'edges', 'localizedStrings'];
+// New generation: never attempt to alter a historical primary key in place.
+export const DATABASE_SCHEMA = {
+ projects: 'id,name,createdAt,modifiedAt',
+ dialogues: 'id,projectId,name,createdAt,modifiedAt',
+ participants: 'id,projectId,name,category,categoryId',
+ categories: 'id,projectId,name,parentCategoryId',
+ decorators: 'id,projectId,name,type',
+ conditions: 'id,projectId,name,type',
+ nodes: '[dialogueId+id],dialogueId,type',
+ edges: '[dialogueId+id],dialogueId,source,target',
+ localizedStrings: '[projectId+key],projectId,dialogueId,nodeId,rowId,field,modifiedAt',
+ syncAccounts: 'provider,accountId,email,expiresAt',
+ syncProjects: '[projectId+provider],projectId,provider,revision,remoteFileId,lastSyncedAt',
+ syncDeletions: '[projectId+provider],projectId,provider,deletedAt',
+ syncCatalogState: '[provider+profileId],provider,profileId,catalogRevision,fetchedAt,status',
+ syncTombstones: '[provider+entityType+entityId],provider,entityType,entityId,projectId,deletedAt,expiresAt,pending,acknowledgedAt',
+ projectState: 'projectId,revisionId,sequence,modifiedAt',
+ projectRevisions: 'id,projectId,createdAt',
+ syncOutbox: 'id,[provider+projectId],projectId,provider,status,createdAt',
+ syncConflicts: 'id,projectId,provider,status,createdAt',
+ recoveryRecords: 'id,projectId,table,code,status',
+ migrationState: 'key',
+};
diff --git a/src/lib/persistence/zipConstants.js b/src/lib/persistence/zipConstants.js
new file mode 100644
index 0000000..7ce04de
--- /dev/null
+++ b/src/lib/persistence/zipConstants.js
@@ -0,0 +1,7 @@
+// ZIP signatures and CRC-32 parameters. Values are unsigned 32-bit; `>>> 0`
+// keeps them in the unsigned range wherever bitwise operators are involved.
+export const ZIP_LOCAL_SIG = 0x04034b50 >>> 0;
+export const ZIP_CENTRAL_SIG = 0x02014b50 >>> 0;
+export const ZIP_EOCD_SIG = 0x06054b50 >>> 0;
+export const CRC32_POLY = 0xedb88320 >>> 0;
+export const CRC32_INIT = 0xffffffff >>> 0;
diff --git a/src/lib/profile/activeProfile.js b/src/lib/profile/activeProfile.js
index 7fe4f3f..c15c46b 100644
--- a/src/lib/profile/activeProfile.js
+++ b/src/lib/profile/activeProfile.js
@@ -1,10 +1,22 @@
const ACTIVE_PROFILE_KEY = 'mountea-active-profile-id';
const DEFAULT_PROFILE_ID = 'local';
+let generation = 0;
+const listeners = new Set();
+export function getProfileGeneration() { return generation; }
+export function subscribeProfileChanges(listener) { listeners.add(listener); return () => listeners.delete(listener); }
+function notifyProfileChange(profileId) {
+ generation += 1;
+ for (const listener of listeners) listener(profileId, generation);
+}
+if (typeof window !== 'undefined') window.addEventListener('storage', (event) => {
+ if (event.key === ACTIVE_PROFILE_KEY) notifyProfileChange(getActiveProfileId());
+});
function sanitizeProfileId(rawValue) {
const value = String(rawValue || '').trim();
if (!value) return DEFAULT_PROFILE_ID;
- return value.replace(/[^a-zA-Z0-9_-]/g, '-');
+ if (!/^[a-zA-Z0-9_-]+$/.test(value)) throw new Error('Invalid profile identifier. Use letters, numbers, underscores, or hyphens; profile identifiers must not alias another profile.');
+ return value;
}
function canUseStorage() {
@@ -20,7 +32,9 @@ export function getActiveProfileId() {
export function setActiveProfileId(profileId) {
if (!canUseStorage()) return DEFAULT_PROFILE_ID;
const normalized = sanitizeProfileId(profileId);
+ const previous = getActiveProfileId();
window.localStorage.setItem(ACTIVE_PROFILE_KEY, normalized);
+ if (previous !== normalized) notifyProfileChange(normalized);
return normalized;
}
@@ -50,6 +64,9 @@ export function readProfileScopedItem(baseKey, fallback = '') {
if (scopedValue !== null) return scopedValue;
// Legacy fallback for pre-profile data; migrate on first read.
+ // Global historical settings belong to the local profile, never every Steam
+ // account that happens to sign in on this machine.
+ if (getActiveProfileId() !== DEFAULT_PROFILE_ID) return fallback;
const legacyValue = window.localStorage.getItem(baseKey);
if (legacyValue === null) return fallback;
window.localStorage.setItem(scopedKey, legacyValue);
diff --git a/src/lib/recoveryEvidence.js b/src/lib/recoveryEvidence.js
new file mode 100644
index 0000000..df3cf8b
--- /dev/null
+++ b/src/lib/recoveryEvidence.js
@@ -0,0 +1,20 @@
+import { serializeAudio } from '@/lib/persistence/canonicalProject';
+
+/** Keep binary evidence durable before presenting it as editable/downloadable JSON. */
+export async function serializeRecoveryEvidence(value) {
+ if (value instanceof Blob) return { recoveredBlob: await serializeAudio({ blob: value }, 'recovery.original'), ...(value instanceof File ? { name: value.name, lastModified: value.lastModified } : {}) };
+ if (value instanceof Date) return { recoveredType: 'Date', value: Number.isNaN(value.getTime()) ? null : value.toISOString() };
+ if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
+ const bytes = value instanceof ArrayBuffer ? new Uint8Array(value) : new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
+ let binary = ''; for (let offset = 0; offset < bytes.length; offset += 32768) binary += String.fromCharCode(...bytes.subarray(offset, offset + 32768));
+ return { recoveredType: value.constructor.name, base64: btoa(binary), byteLength: bytes.length };
+ }
+ if (!value || typeof value !== 'object') return value;
+ if (value.blob instanceof Blob) {
+ const original = Object.fromEntries(await Promise.all(Object.entries(value).map(async ([key, item]) => [key, await serializeRecoveryEvidence(item)])));
+ const audio = await serializeAudio({ blob: value.blob }, 'recovery.original.audioFile');
+ return { ...original, ...audio, recoveredOriginal: original };
+ }
+ if (Array.isArray(value)) return Promise.all(value.map(serializeRecoveryEvidence));
+ return Object.fromEntries(await Promise.all(Object.entries(value).map(async ([key, item]) => [key, await serializeRecoveryEvidence(item)])));
+}
diff --git a/src/lib/storageUtils.js b/src/lib/storageUtils.js
index ec85e2a..7ac1657 100644
--- a/src/lib/storageUtils.js
+++ b/src/lib/storageUtils.js
@@ -1,128 +1,32 @@
-import { db } from './db';
+import { getRepositoryContext } from './db';
+import { buildProjectSnapshot } from './sync/snapshot';
-/**
- * Calculate the estimated size of IndexedDB storage
- * Uses StorageManager API when available
- */
+/** Origin usage includes databases, indexes and caches; it is not project payload size. */
export async function calculateDiskUsage() {
- try {
- // Try using StorageManager API if available
- if ('storage' in navigator && 'estimate' in navigator.storage) {
- const estimate = await navigator.storage.estimate();
- return estimate.usage || 0;
- }
-
- // Fallback: Estimate based on data size
- return await estimateDataSize();
- } catch (error) {
- console.error('Error calculating disk usage:', error);
- return 0;
- }
+ try { return (await navigator.storage?.estimate?.())?.usage ?? null; }
+ catch { return null; }
}
-/**
- * Estimate data size by counting records and approximating
- */
-async function estimateDataSize() {
- try {
- const [
- projectCount,
- dialogueCount,
- nodeCount,
- edgeCount,
- participantCount,
- categoryCount,
- decoratorCount,
- ] = await Promise.all([
- db.projects.count(),
- db.dialogues.count(),
- db.nodes.count(),
- db.edges.count(),
- db.participants.count(),
- db.categories.count(),
- db.decorators.count(),
- ]);
-
- // Rough estimates (in bytes):
- // Project: ~500 bytes
- // Dialogue: ~300 bytes
- // Node: ~200 bytes
- // Edge: ~100 bytes
- // Participant/Category/Decorator: ~150 bytes each
-
- const estimatedSize =
- projectCount * 500 +
- dialogueCount * 300 +
- nodeCount * 200 +
- edgeCount * 100 +
- (participantCount + categoryCount + decoratorCount) * 150;
-
- return estimatedSize;
- } catch (error) {
- console.error('Error estimating data size:', error);
- return 0;
- }
+export function serializedPayloadBytes(payload) {
+ return new TextEncoder().encode(JSON.stringify(payload)).byteLength;
}
-/**
- * Calculate size of a specific project including all related data
- */
+/** Canonical UTF-8 JSON with encoded media, strings and all unused definitions.
+ * This is a payload estimate, not ZIP size or allocated IndexedDB disk usage. */
export async function calculateProjectSize(projectId) {
- try {
- // Get all dialogues for this project
- const dialogues = await db.dialogues.where('projectId').equals(projectId).toArray();
- const dialogueIds = dialogues.map((d) => d.id);
-
- // Count nodes and edges for these dialogues
- let nodeCount = 0;
- let edgeCount = 0;
-
- for (const dialogueId of dialogueIds) {
- const nodes = await db.nodes.where('dialogueId').equals(dialogueId).count();
- const edges = await db.edges.where('dialogueId').equals(dialogueId).count();
- nodeCount += nodes;
- edgeCount += edges;
- }
-
- // Count project-specific data
- const participantCount = await db.participants
- .where('projectId')
- .equals(projectId)
- .count();
- const categoryCount = await db.categories.where('projectId').equals(projectId).count();
- const decoratorCount = await db.decorators.where('projectId').equals(projectId).count();
-
- // Calculate estimated size
- const estimatedSize =
- 500 + // Project itself
- dialogues.length * 300 +
- nodeCount * 200 +
- edgeCount * 100 +
- (participantCount + categoryCount + decoratorCount) * 150;
-
- return estimatedSize;
- } catch (error) {
- console.error('Error calculating project size:', error);
- return 0;
- }
+ return serializedPayloadBytes(await buildProjectSnapshot(projectId));
}
-/**
- * Calculate size of a specific dialogue including nodes and edges
- */
export async function calculateDialogueSize(dialogueId) {
- try {
- const nodeCount = await db.nodes.where('dialogueId').equals(dialogueId).count();
- const edgeCount = await db.edges.where('dialogueId').equals(dialogueId).count();
-
- // Dialogue + nodes + edges
- const estimatedSize = 300 + nodeCount * 200 + edgeCount * 100;
-
- return estimatedSize;
- } catch (error) {
- console.error('Error calculating dialogue size:', error);
- return 0;
- }
+ const context = await getRepositoryContext();
+ const dialogue = await context.db.dialogues.get(dialogueId);
+ if (!dialogue) return 0;
+ const snapshot = await buildProjectSnapshot(dialogue.projectId, { context });
+ snapshot.dialogues = snapshot.dialogues.filter((row) => row.id === dialogueId);
+ for (const table of ['nodes', 'edges']) snapshot[table] = snapshot[table].filter((row) => row.dialogueId === dialogueId);
+ snapshot.localizedStrings = snapshot.localizedStrings.filter((row) => !row.dialogueId || row.dialogueId === dialogueId);
+ context.assertCurrent();
+ return serializedPayloadBytes(snapshot);
}
/**
diff --git a/src/lib/sync/core/providerCatalog.js b/src/lib/sync/core/providerCatalog.js
index 0c0b190..59606cd 100644
--- a/src/lib/sync/core/providerCatalog.js
+++ b/src/lib/sync/core/providerCatalog.js
@@ -1,6 +1,4 @@
-import { getActiveProfileId } from '@/lib/profile/activeProfile';
import {
- MIME_TYPE,
SYNC_CATALOG_FILE_NAME,
SYNC_CATALOG_SCHEMA_VERSION,
} from '@/lib/sync/core/constants';
@@ -124,12 +122,8 @@ export async function readProviderCatalog(provider) {
}
const rawText = await storage.downloadFile(remoteFile.id);
- let parsed = null;
- try {
- parsed = JSON.parse(rawText);
- } catch (error) {
- parsed = null;
- }
+ const parsed = JSON.parse(rawText);
+ if (parsed?.schemaVersion !== 1 || !Array.isArray(parsed?.objects?.projects) || !Array.isArray(parsed?.objects?.dialogues) || !Array.isArray(parsed?.tombstones)) throw new Error('Corrupt or unsupported legacy catalogue.');
return {
providerId,
@@ -150,59 +144,8 @@ export async function readProviderCatalog(provider) {
}
}
-export async function writeProviderCatalog(provider, catalog, options = {}) {
- const { providerId, storage } = getSyncContext({ provider });
- const nowIso = new Date().toISOString();
- const normalized = normalizeProviderCatalog(catalog);
- const nextCatalog = {
- ...normalized,
- catalogRevision:
- options?.bumpRevision === false
- ? toRevision(normalized.catalogRevision)
- : toRevision(normalized.catalogRevision) + 1,
- updatedAt: nowIso,
- };
-
- const appProperties = {
- schemaVersion: String(SYNC_CATALOG_SCHEMA_VERSION),
- catalogRevision: String(nextCatalog.catalogRevision),
- profileId: String(getActiveProfileId() || 'local'),
- updatedAt: nowIso,
- kind: 'mountea-sync-catalog',
- };
-
- const content = JSON.stringify(stripCatalogTransientFields(nextCatalog));
- let fileId = String(options?.fileId || '').trim();
- if (!fileId) {
- const existing = await storage.findFileByName(SYNC_CATALOG_FILE_NAME);
- fileId = String(existing?.id || '').trim();
- }
-
- if (fileId) {
- const result = await storage.updateFile({
- fileId,
- content,
- mimeType: MIME_TYPE,
- appProperties,
- });
- return {
- providerId,
- fileId: String(result?.id || fileId),
- catalog: nextCatalog,
- };
- }
-
- const result = await storage.createFile({
- name: SYNC_CATALOG_FILE_NAME,
- content,
- mimeType: MIME_TYPE,
- appProperties,
- });
- return {
- providerId,
- fileId: String(result?.id || ''),
- catalog: nextCatalog,
- };
+export async function writeProviderCatalog() {
+ throw new Error('Legacy catalogues are read-only. Publish immutable revisions instead.');
}
function compareByUpdatedAtThenRevision(current, candidate) {
@@ -239,7 +182,6 @@ function compareByDeletedAtThenExpiry(current, candidate) {
export function mergeProviderCatalogs(catalogEntries = []) {
const nowIso = new Date().toISOString();
- const nowMs = Date.now();
const projectMap = new Map();
const dialogueMap = new Map();
const tombstoneMap = new Map();
@@ -289,37 +231,7 @@ export function mergeProviderCatalogs(catalogEntries = []) {
}
}
- const activeTombstones = Array.from(tombstoneMap.values()).filter((item) => {
- const expiresAtMs = item?.expiresAt ? Date.parse(item.expiresAt) : 0;
- return !(expiresAtMs && expiresAtMs <= nowMs);
- });
-
- const projectTombstones = new Set(
- activeTombstones
- .filter((item) => item.entityType === 'project')
- .map((item) => String(item.entityId || '').trim())
- .filter(Boolean)
- );
- const dialogueTombstones = new Set(
- activeTombstones
- .filter((item) => item.entityType === 'dialogue')
- .map((item) => String(item.entityId || '').trim())
- .filter(Boolean)
- );
-
- for (const projectId of projectTombstones) {
- projectMap.delete(projectId);
- }
-
- for (const dialogueId of dialogueTombstones) {
- dialogueMap.delete(dialogueId);
- }
-
- for (const [dialogueId, dialogue] of dialogueMap.entries()) {
- if (projectTombstones.has(String(dialogue?.projectId || '').trim())) {
- dialogueMap.delete(dialogueId);
- }
- }
+ const activeTombstones = Array.from(tombstoneMap.values());
return {
schemaVersion: SYNC_CATALOG_SCHEMA_VERSION,
diff --git a/src/lib/sync/core/revisionProtocol.js b/src/lib/sync/core/revisionProtocol.js
new file mode 100644
index 0000000..de96302
--- /dev/null
+++ b/src/lib/sync/core/revisionProtocol.js
@@ -0,0 +1,364 @@
+import { getRepositoryContext, assertProjectReady } from '@/lib/db';
+import { readProjectState, mutateProject, applyRemoteRevision, prepareProjectCommit, commitPreparedProject } from '@/lib/persistence/projectRepository';
+import { canonicalizeProject, hashProject } from '@/lib/persistence/canonicalProject';
+import { applyProjectSnapshotAsNew } from '@/lib/sync/snapshot';
+import { encryptPayload, decryptPayload } from '@/lib/sync/crypto';
+import { getSyncContext } from './providerGateway';
+import { MAX_SYNC_PAYLOAD_BYTES, SYNC_CATALOG_FILE_NAME } from './constants';
+
+export const REVISION_PREFIX = 'mountea-revision-v2--';
+const DELETION_PREFIX = 'mountea-deletion-v2--';
+const locks = new WeakMap();
+const now = () => new Date().toISOString();
+const error = (code, message) => Object.assign(new Error(message), { code });
+export const revisionFileName = async (projectId, revisionId) => {
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify([projectId, revisionId])));
+ return `${REVISION_PREFIX}${[...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('')}.json`;
+};
+const ordered = value => Array.isArray(value) ? value.map(ordered) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, ordered(value[key])])) : value;
+const equalRevision = (a, b) => JSON.stringify(ordered(a)) === JSON.stringify(ordered(b));
+async function optionsFor(options) {
+ const context = options.context || await getRepositoryContext(); context.assertCurrent();
+ const gateway = options.storage ? { storage: options.storage, providerId: options.provider || options.storage.id } : getSyncContext(options);
+ const storage = { ...gateway.storage };
+ for (const method of ['listFiles', 'findFileByName', 'downloadFile', 'createFile', 'updateFile', 'deleteFile']) {
+ storage[method] = argument => new Promise((resolve, reject) => {
+ const abort = () => { try { context.assertCurrent(); reject(error('STALE_PROFILE', 'Repository operation was cancelled.')); } catch (failure) { reject(failure); } };
+ if (context.signal.aborted) { abort(); return; }
+ context.signal.addEventListener('abort', abort, { once: true });
+ Promise.resolve().then(() => { context.assertCurrent(); return gateway.storage[method](argument, { context }); }).then(
+ result => { context.signal.removeEventListener('abort', abort); resolve(result); },
+ failure => { context.signal.removeEventListener('abort', abort); reject(failure); }
+ );
+ });
+ }
+ return { ...options, ...gateway, storage, context };
+}
+function parse(text) {
+ if (typeof text !== 'string' || new TextEncoder().encode(text).length > MAX_SYNC_PAYLOAD_BYTES) throw error('CORRUPT_REMOTE', 'Remote object is invalid or oversized.');
+ try { return JSON.parse(text); } catch { throw error('CORRUPT_REMOTE', 'Remote object contains malformed JSON.'); }
+}
+async function validateRevision(revision) {
+ if (!revision || typeof revision.id !== 'string' || !revision.id || typeof revision.projectId !== 'string' || !revision.projectId || !Array.isArray(revision.parentRevisionIds) || revision.parentRevisionIds.some(id => typeof id !== 'string' || !id || id === revision.id) || !['create','update','delete','restore','resolve'].includes(revision.operation) || typeof revision.deviceId !== 'string' || !revision.deviceId || !Number.isFinite(Date.parse(revision.createdAt))) throw error('CORRUPT_REMOTE', 'Remote revision metadata is invalid.');
+ const snapshot = await canonicalizeProject(revision.snapshot, { allowIncompleteMedia: true });
+ if (snapshot.project.id !== revision.projectId || await hashProject(snapshot) !== revision.payloadHash) throw error('CORRUPT_REMOTE', 'Remote revision payload hash does not match.');
+ // History may contain drafts subsequently repaired by a descendant. Applying a
+ // head still uses the repository's strict validation before any authoring write.
+ return { ...revision, snapshot };
+}
+async function decodeRevision(text, options) {
+ const envelope = parse(text);
+ if (envelope.schemaVersion !== 2) throw error('UNSUPPORTED_REMOTE', 'Unsupported immutable revision protocol.');
+ const revision = options.providerId === 'steam' ? envelope.revision : await decryptPayload(options.passphrase, envelope.encrypted);
+ return validateRevision(revision);
+}
+async function encodeRevision(revision, options) {
+ const envelope = options.providerId === 'steam' ? { schemaVersion: 2, revision } : { schemaVersion: 2, encrypted: await encryptPayload(options.passphrase, revision) };
+ const content = JSON.stringify(envelope);
+ if (new TextEncoder().encode(content).length > MAX_SYNC_PAYLOAD_BYTES) throw error('PAYLOAD_QUOTA', 'Revision exceeds the sync payload limit; it remains pending.');
+ return content;
+}
+function addRevision(map, revision) {
+ const previous = map.get(revision.id);
+ if (previous && !equalRevision(previous, revision)) throw error('REVISION_COLLISION', 'An immutable revision identity has different content.');
+ map.set(revision.id, revision);
+}
+export function ancestry(ancestorId, descendantId, revisions) {
+ const visited = new Set(), pending = [descendantId]; let incomplete = false;
+ while (pending.length) {
+ const id = pending.pop(); if (id === ancestorId) return true;
+ if (visited.has(id)) continue; visited.add(id);
+ const revision = revisions.get(id); if (!revision) { incomplete = true; continue; }
+ pending.push(...revision.parentRevisionIds);
+ }
+ return incomplete ? null : false;
+}
+export function completeAncestry(id, revisions) {
+ const visiting = new Set(), complete = new Set(), stack = [{ id, exit: false }];
+ let missing = false;
+ while (stack.length) {
+ const frame = stack.pop();
+ if (frame.exit) { visiting.delete(frame.id); complete.add(frame.id); continue; }
+ if (complete.has(frame.id)) continue;
+ if (visiting.has(frame.id)) throw error('CORRUPT_REMOTE', 'Remote revision history contains a cycle.');
+ const revision = revisions.get(frame.id); if (!revision) { missing = true; continue; }
+ visiting.add(frame.id); stack.push({ id: frame.id, exit: true });
+ for (const parent of revision.parentRevisionIds) {
+ if (revisions.has(parent) && revisions.get(parent).projectId !== revision.projectId) throw error('CORRUPT_REMOTE', 'Revision parent belongs to another project.');
+ stack.push({ id: parent, exit: false });
+ }
+ }
+ return !missing;
+}
+function headsFor(revisions, projectId, ids) {
+ const candidates = [...new Set(ids)].filter(id => revisions.get(id)?.projectId === projectId);
+ return candidates.filter(id => !candidates.some(other => id !== other && ancestry(id, other, revisions) === true));
+}
+async function legacyRevision(text, options) {
+ const envelope = parse(text);
+ const legacyPassphrase = options.providerId === 'steam' ? `auto:steam:${options.context.profileId}:v1` : options.passphrase;
+ const raw = envelope.project ? envelope : await decryptPayload(legacyPassphrase, envelope);
+ const snapshot = await canonicalizeProject(raw);
+ const payloadHash = await hashProject(snapshot);
+ return validateRevision({ id: `legacy-${payloadHash}`, projectId: snapshot.project.id, parentRevisionIds: [], deviceId: 'legacy-import', payloadHash, operation: 'update', snapshot, createdAt: snapshot.project.modifiedAt || snapshot.project.createdAt || '1970-01-01T00:00:00.000Z' });
+}
+function validateTombstone(item) {
+ if (!item || !['project','dialogue'].includes(item.entityType) || typeof item.entityId !== 'string' || !item.entityId || !Number.isFinite(Date.parse(item.deletedAt))) throw error('CORRUPT_REMOTE', 'Deletion evidence is malformed.');
+ return { entityType: item.entityType, entityId: item.entityId, projectId: item.projectId || (item.entityType === 'project' ? item.entityId : ''), deletedAt: item.deletedAt };
+}
+/** Read-only discovery: catalogue absence never implies deletion or a complete listing. */
+export async function inspectRevisions(input = {}) {
+ const options = await optionsFor(input), { context, storage } = options;
+ const revisions = new Map(), remoteIds = new Set(), fileIds = new Map(), tombstones = [];
+ for (const revision of await context.db.projectRevisions.toArray()) addRevision(revisions, revision);
+ context.assertCurrent();
+ const files = await storage.listFiles({}); context.assertCurrent();
+ const unique = new Map(files.map(file => [file.id, file]));
+ const catalog = await storage.findFileByName(SYNC_CATALOG_FILE_NAME); context.assertCurrent();
+ if (catalog?.id) unique.set(catalog.id, { ...catalog, name: SYNC_CATALOG_FILE_NAME });
+ for (const file of unique.values()) {
+ const name = file.name || '';
+ if (!name.startsWith(REVISION_PREFIX) && !name.startsWith(DELETION_PREFIX) && !/^mountea-project-.*\.(mnteasnap|mteasnap)$/.test(name) && name !== SYNC_CATALOG_FILE_NAME) continue;
+ const text = await storage.downloadFile(file.id); context.assertCurrent();
+ if (name === SYNC_CATALOG_FILE_NAME) {
+ const catalogData = parse(text);
+ if (catalogData.schemaVersion !== 1) throw error('UNSUPPORTED_REMOTE', 'Unsupported legacy catalogue version.');
+ if (!catalogData.objects || !Array.isArray(catalogData.objects.projects) || !Array.isArray(catalogData.objects.dialogues) || !Array.isArray(catalogData.tombstones)) throw error('CORRUPT_REMOTE', 'Legacy catalogue is corrupt.');
+ tombstones.push(...catalogData.tombstones.map(validateTombstone));
+ // The catalogue is only a positive hint. Missing hinted objects fail closed.
+ for (const entry of catalogData.objects.projects) {
+ if (!entry.snapshotFileId || unique.has(entry.snapshotFileId)) continue;
+ const revision = await legacyRevision(await storage.downloadFile(entry.snapshotFileId), options); context.assertCurrent();
+ addRevision(revisions, revision); remoteIds.add(revision.id); fileIds.set(revision.id, entry.snapshotFileId);
+ }
+ } else if (name.startsWith(DELETION_PREFIX)) {
+ const envelope = parse(text); if (envelope.schemaVersion !== 2) throw error('UNSUPPORTED_REMOTE', 'Unsupported deletion protocol.');
+ tombstones.push(validateTombstone(envelope.tombstone));
+ } else {
+ const revision = name.startsWith(REVISION_PREFIX) ? await decodeRevision(text, options) : await legacyRevision(text, options);
+ if (name.startsWith(REVISION_PREFIX) && name !== await revisionFileName(revision.projectId, revision.id)) throw error('CORRUPT_REMOTE', 'Remote filename does not match its immutable revision.');
+ addRevision(revisions, revision); remoteIds.add(revision.id); fileIds.set(revision.id, file.id);
+ }
+ }
+ // Fetch positively referenced ancestors by deterministic name even when listing is delayed.
+ const queue = [...remoteIds], visited = new Set();
+ while (queue.length) {
+ const id = queue.pop(); if (visited.has(id)) continue; visited.add(id);
+ const revision = revisions.get(id);
+ for (const parentId of revision.parentRevisionIds) {
+ if (!revisions.has(parentId)) {
+ const file = await storage.findFileByName(await revisionFileName(revision.projectId, parentId)); context.assertCurrent();
+ if (!file?.id) continue;
+ const parent = await decodeRevision(await storage.downloadFile(file.id), options); context.assertCurrent();
+ if (parent.id !== parentId || parent.projectId !== revision.projectId) throw error('CORRUPT_REMOTE', 'Ancestor identity mismatch.');
+ addRevision(revisions, parent); fileIds.set(parent.id, file.id); remoteIds.add(parent.id);
+ }
+ if (revisions.has(parentId)) queue.push(parentId);
+ }
+ }
+ // Historic tombstones have no expiry. Keep every positive observation indefinitely.
+ for (const row of await context.db.syncTombstones.toArray()) tombstones.push(validateTombstone(row));
+ for (const row of await context.db.syncDeletions.toArray()) tombstones.push(validateTombstone({ ...row, entityType: 'project', entityId: row.projectId }));
+ context.assertCurrent();
+ const deletionMap = new Map(tombstones.map(row => [`${row.entityType}:${row.entityId}:${row.deletedAt}`, row]));
+ return { options, revisions, remoteIds, fileIds, tombstones: [...deletionMap.values()], catalogueStatus: catalog?.id ? 'readable' : 'missing' };
+}
+async function cacheRevision(revision, context) {
+ await context.db.transaction('rw', context.db.projectRevisions, async () => { context.assertCurrent(); const previous = await context.db.projectRevisions.get(revision.id); if (previous && !equalRevision(previous, revision)) throw error('REVISION_COLLISION', 'Immutable cached revision differs.'); if (!previous) await context.db.projectRevisions.add(revision); });
+}
+async function publishRevision(revision, options) {
+ const { context, providerId, storage } = options;
+ const state = await context.db.projectState.get(revision.projectId);
+ if (!state?.deleted) await assertProjectReady(revision.projectId, context);
+ const key = `delivery:${providerId}:${revision.id}`;
+ const delivery = await context.db.syncOutbox.get(key); context.assertCurrent();
+ if (delivery?.status === 'acknowledged') return;
+ await context.db.syncOutbox.put({ id: key, provider: providerId, projectId: revision.projectId, localRevisionId: revision.id, payloadHash: revision.payloadHash, status: 'pending', phase: 'queued', createdAt: delivery?.createdAt || now() });
+ const name = await revisionFileName(revision.projectId, revision.id);
+ try {
+ let file = await storage.findFileByName(name); context.assertCurrent();
+ if (!file?.id) {
+ const content = await encodeRevision(revision, options); context.assertCurrent();
+ file = await storage.createFile({ name, content, mimeType: 'application/json', appProperties: { projectId: revision.projectId, revisionId: revision.id, payloadHash: revision.payloadHash, protocol: '2' } }); context.assertCurrent();
+ }
+ await context.db.syncOutbox.update(key, { phase: 'uploaded', remoteFileId: file.id }); context.assertCurrent();
+ const verified = await decodeRevision(await storage.downloadFile(file.id), options); context.assertCurrent();
+ if (!equalRevision(verified, revision)) throw error('REMOTE_VERIFY_FAILED', 'Uploaded revision failed exact content verification.');
+ await context.db.transaction('rw', context.db.syncOutbox, context.db.syncProjects, async () => {
+ context.assertCurrent();
+ await context.db.syncOutbox.update(key, { status: 'acknowledged', phase: 'verified', remoteFileId: file.id, acknowledgedAt: now(), error: '' });
+ await context.db.syncProjects.put({ projectId: revision.projectId, provider: providerId, acknowledgedRevisionId: revision.id, remoteFileId: file.id, lastSyncedAt: now() });
+ });
+ } catch (failure) {
+ if (!context.signal.aborted) await context.db.syncOutbox.update(key, { status: 'pending', error: failure.code || 'UPLOAD_FAILED' });
+ throw failure;
+ }
+}
+async function publishDeletionEvidence(tombstone, options) {
+ const { context, storage } = options;
+ const content = JSON.stringify({ schemaVersion: 2, tombstone });
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(content));
+ const id = [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2,'0')).join('');
+ const name = `${DELETION_PREFIX}${id}.json`;
+ let file = await storage.findFileByName(name); context.assertCurrent();
+ if (!file?.id) { file = await storage.createFile({ name, content, mimeType: 'application/json' }); context.assertCurrent(); }
+ if (await storage.downloadFile(file.id) !== content) throw error('REMOTE_VERIFY_FAILED', 'Deletion evidence failed remote verification.');
+ context.assertCurrent();
+}
+async function tombstoneRevision(tombstone, base, revisions) {
+ const projectId = tombstone.projectId;
+ if (!projectId) return null;
+ const snapshot = base ? structuredClone(base.snapshot) : await canonicalizeProject({ project: { id: projectId, name: 'Deleted project' }, dialogues: [], nodes: [], edges: [], participants: [], categories: [], decorators: [], conditions: [], localizedStrings: [] });
+ const parents = [...revisions.values()].filter(row => row.projectId === projectId && row.deviceId === 'legacy-import' && Date.parse(row.createdAt) <= Date.parse(tombstone.deletedAt)).map(row => row.id).sort();
+ if (tombstone.entityType === 'dialogue') {
+ snapshot.dialogues = snapshot.dialogues.filter(row => row.id !== tombstone.entityId);
+ snapshot.nodes = snapshot.nodes.filter(row => row.dialogueId !== tombstone.entityId);
+ snapshot.edges = snapshot.edges.filter(row => row.dialogueId !== tombstone.entityId);
+ snapshot.localizedStrings = snapshot.localizedStrings.filter(row => row.dialogueId !== tombstone.entityId);
+ }
+ const payloadHash = await hashProject(snapshot);
+ // Bind identity to the exact selected baseline, retaining all deletion observations.
+ const idHash = await hashProject({ tombstone, payloadHash, parents });
+ return { id: `deletion-${idHash}`, projectId, parentRevisionIds: parents, deviceId: 'legacy-deletion', operation: tombstone.entityType === 'project' ? 'delete' : 'update', payloadHash, snapshot, createdAt: tombstone.deletedAt };
+}
+async function recordConflict(projectId, localId, remoteIds, context, providerId, incomplete = false) {
+ const ids = [...new Set([localId, ...remoteIds].filter(Boolean))].sort();
+ const id = `${providerId}:${projectId}:${ids.join(':')}`;
+ await context.db.syncConflicts.put({ id, projectId, provider: providerId, localRevisionId: localId, revisionIds: ids, status: 'unresolved', incomplete, createdAt: now() });
+ return id;
+}
+async function runUnlocked(input) {
+ const inspected = await inspectRevisions(input);
+ const { options, revisions, remoteIds, tombstones } = inspected, { context, providerId, mode = 'full' } = options;
+ if (!['list','pull','push','full'].includes(mode)) throw error('INVALID_SYNC_MODE', 'Unsupported sync mode.');
+ const states = await context.db.projectState.toArray(), projects = await context.db.projects.toArray(); context.assertCurrent();
+ const comparisons = [], failures = [], conflicts = [];
+ if (mode === 'list') {
+ const ids = new Set([...projects.map(row => row.id), ...[...remoteIds].map(id => revisions.get(id).projectId)]);
+ for (const projectId of ids) comparisons.push({ projectId, localRevisionId: states.find(row => row.projectId === projectId)?.revisionId || null, remoteRevisionIds: headsFor(revisions, projectId, [...remoteIds]) });
+ return { mode, comparisons, conflicts, failures, catalogueStatus: inspected.catalogueStatus };
+ }
+ // Existing authoring without a revision receives one durable baseline before reconciliation.
+ for (const project of projects) if (!states.some(state => state.projectId === project.id)) {
+ const committed = await mutateProject(project.id, { context });
+ const revision = await context.db.projectRevisions.get(committed.revisionId); addRevision(revisions, revision);
+ }
+ for (const id of remoteIds) await cacheRevision(revisions.get(id), context);
+ for (const tombstone of tombstones) {
+ const base = [...revisions.values()].filter(row => row.projectId === tombstone.projectId && row.deviceId === 'legacy-import').sort((a,b) => a.id.localeCompare(b.id))[0];
+ const revision = await tombstoneRevision(tombstone, base, revisions);
+ if (revision) { addRevision(revisions, revision); remoteIds.add(revision.id); await cacheRevision(revision, context); }
+ await context.db.syncTombstones.put({ ...tombstone, provider: providerId, pending: true });
+ }
+ const freshStates = await context.db.projectState.toArray(); context.assertCurrent();
+ const ids = new Set([...freshStates.map(row => row.projectId), ...[...remoteIds].map(id => revisions.get(id).projectId)]);
+ for (const projectId of ids) {
+ if (options.projectId && options.projectId !== projectId) continue;
+ const current = await readProjectState(projectId, context), localId = current.revisionId;
+ const candidates = [...remoteIds].filter(id => revisions.get(id).projectId === projectId);
+ if (localId) candidates.push(localId);
+ const heads = headsFor(revisions, projectId, candidates);
+ const incomplete = heads.some(id => !completeAncestry(id, revisions));
+ comparisons.push({ projectId, localRevisionId: localId, remoteRevisionIds: heads });
+ if (!incomplete && heads.length === 1) {
+ for (const conflict of await context.db.syncConflicts.where('projectId').equals(projectId).toArray()) {
+ if (conflict.status === 'unresolved' && conflict.revisionIds.every(id => ancestry(id, heads[0], revisions) === true)) await context.db.syncConflicts.update(conflict.id, { status: 'resolved', resolvedRevisionId: heads[0], resolvedAt: now() });
+ }
+ }
+ if (incomplete || heads.length > 1) {
+ conflicts.push(await recordConflict(projectId, localId, heads, context, providerId, incomplete));
+ } else if (heads.length === 1 && heads[0] !== localId && mode !== 'push') {
+ const revision = revisions.get(heads[0]);
+ try { await applyRemoteRevision(revision, { context, expectedSequence: current.sequence }); }
+ catch (failure) { failures.push({ projectId, code: failure.code || 'APPLY_FAILED', message: failure.message }); }
+ }
+ }
+ if (mode === 'full' || mode === 'push') {
+ // Publish immutable ancestry, including conflicting local branches. Acknowledgements
+ // target only each captured revision; a concurrent edit remains independently pending.
+ const pending = await context.db.syncOutbox.filter(row => !row.provider).toArray(); context.assertCurrent();
+ const published = new Set();
+ const publishTree = async (id) => {
+ // Include edits committed while discovery was in flight without acknowledging
+ // or dropping a different revision. Work arriving later waits for the retry.
+ if (!revisions.has(id)) { const row = await context.db.projectRevisions.get(id); if (row) addRevision(revisions, row); }
+ if (!completeAncestry(id, revisions)) throw error('MISSING_ANCESTOR', 'Incomplete revision ancestry remains pending.');
+ const stack = [{ id, exit: false }];
+ while (stack.length) {
+ const frame = stack.pop(); if (published.has(frame.id)) continue;
+ const revision = revisions.get(frame.id);
+ if (frame.exit) { await publishRevision(revision, options); published.add(frame.id); continue; }
+ stack.push({ id: frame.id, exit: true });
+ for (const parent of revision.parentRevisionIds) stack.push({ id: parent, exit: false });
+ }
+ };
+ for (const item of pending) {
+ if (options.projectId && options.projectId !== item.projectId) continue;
+ try { await publishTree(item.localRevisionId); } catch (failure) { failures.push({ projectId: item.projectId, code: failure.code || 'UPLOAD_FAILED', message: failure.message }); }
+ }
+ for (const tombstone of tombstones) {
+ try { await publishDeletionEvidence(tombstone, options); await context.db.syncTombstones.update([providerId,tombstone.entityType,tombstone.entityId], { pending: false, acknowledgedAt: now() }); }
+ catch (failure) { failures.push({ projectId: tombstone.projectId, code: failure.code || 'UPLOAD_FAILED', message: failure.message }); }
+ }
+ }
+ context.assertCurrent();
+ return { mode, comparisons, conflicts, failures, catalogueStatus: inspected.catalogueStatus };
+}
+export async function syncRevisions(input = {}) {
+ const options = await optionsFor(input);
+ let providers = locks.get(options.context.db); if (!providers) { providers = new Map(); locks.set(options.context.db, providers); }
+ const previous = providers.get(options.providerId) || Promise.resolve();
+ const work = previous.catch(() => {}).then(async () => {
+ options.context.assertCurrent(); const result = await runUnlocked(options);
+ return { ...result, queue: await getRevisionQueueStatus(options.providerId, options.context) };
+ });
+ providers.set(options.providerId, work);
+ try { return await work; } finally { if (providers.get(options.providerId) === work) providers.delete(options.providerId); }
+}
+
+/** Metadata-only provider view: durable base intents remain for future providers. */
+export async function getRevisionQueueStatus(providerId, suppliedContext) {
+ const context = suppliedContext || await getRepositoryContext(); context.assertCurrent();
+ const rows = await context.db.syncOutbox.toArray();
+ const deliveries = new Map(rows.filter(row => row.provider === providerId).map(row => [row.localRevisionId, row]));
+ const intents = rows.filter(row => !row.provider);
+ const status = { queued: 0, uploaded: 0, verified: 0, pending: 0, oldestPendingAgeMs: 0, conflicts: 0, errorCodes: [] };
+ for (const intent of intents) {
+ const delivery = deliveries.get(intent.localRevisionId);
+ if (delivery?.status === 'acknowledged') { status.verified++; continue; }
+ status.pending++;
+ if (delivery?.phase === 'uploaded') status.uploaded++; else status.queued++;
+ const createdAt = Date.parse(intent.createdAt); if (Number.isFinite(createdAt)) status.oldestPendingAgeMs = Math.max(status.oldestPendingAgeMs, Date.now() - createdAt);
+ if (delivery?.error) status.errorCodes.push(delivery.error);
+ }
+ status.conflicts = await context.db.syncConflicts.filter(row => row.status === 'unresolved' && row.provider === providerId).count();
+ status.errorCodes = [...new Set(status.errorCodes)].sort(); context.assertCurrent(); return status;
+}
+export async function resolveRevisionConflict(conflictId, choice, input = {}) {
+ const context = input.context || await getRepositoryContext(); context.assertCurrent();
+ const conflict = await context.db.syncConflicts.get(conflictId);
+ if (!conflict || conflict.status !== 'unresolved' || conflict.incomplete) throw error('CONFLICT_UNAVAILABLE', 'Reload complete revision history before resolving this conflict.');
+ if (!['local','remote','both'].includes(choice)) throw error('INVALID_RESOLUTION', 'Choose which revision to retain.');
+ const current = await readProjectState(conflict.projectId, context);
+ if (current.revisionId !== conflict.localRevisionId) throw error('STALE_CONFLICT', 'The project changed; synchronize again before resolving.');
+ const revisions = await context.db.projectRevisions.bulkGet(conflict.revisionIds);
+ if (revisions.some(row => !row)) throw error('MISSING_ANCESTOR', 'Conflict history is missing.');
+ const local = revisions.find(row => row.id === conflict.localRevisionId);
+ const remote = revisions.find(row => row.id !== conflict.localRevisionId);
+ if (!remote || (choice === 'remote' && conflict.revisionIds.length > 2 && !input.revisionId)) throw error('CHOOSE_REVISION', 'Select a specific competing revision.');
+ const selected = input.revisionId ? revisions.find(row => row.id === input.revisionId) : choice === 'remote' ? remote : local || remote;
+ if (!selected) throw error('CHOOSE_REVISION', 'Select an available revision.');
+ let copiedProjectId;
+ if (choice === 'both') {
+ for (const competing of revisions.filter(row => row.id !== selected.id && row.operation !== 'delete')) {
+ const digest = await hashProject({ conflictId, revisionId: competing.id });
+ const copyId = `${digest.slice(0,8)}-${digest.slice(8,12)}-4${digest.slice(13,16)}-8${digest.slice(17,20)}-${digest.slice(20,32)}`;
+ copiedProjectId = copyId;
+ if (!await context.db.projectState.get(copyId)) await applyProjectSnapshotAsNew(competing.snapshot, { context, projectId: copyId, name: `${competing.snapshot.project.name} (conflict copy)` });
+ }
+ }
+ const prepared = await prepareProjectCommit(selected.snapshot, { context, expectedSequence: current.sequence, parentRevisionIds: conflict.revisionIds, operation: selected.operation === 'delete' ? 'delete' : 'resolve' });
+ const result = await commitPreparedProject(prepared);
+ await context.db.syncConflicts.update(conflictId, { status: 'resolved', resolvedRevisionId: result.revisionId, resolvedAt: now(), copiedProjectId });
+ return { ...result, copiedProjectId };
+}
diff --git a/src/lib/sync/core/syncActions.js b/src/lib/sync/core/syncActions.js
index 90d28db..5843109 100644
--- a/src/lib/sync/core/syncActions.js
+++ b/src/lib/sync/core/syncActions.js
@@ -1,775 +1,37 @@
-import { db } from '@/lib/db';
-import { getActiveProfileId } from '@/lib/profile/activeProfile';
-import { encryptPayload, decryptPayload } from '@/lib/sync/crypto';
-import {
- MIME_TYPE,
- MAX_SYNC_PAYLOAD_BYTES,
- SYNC_TOMBSTONE_TTL_MS,
-} from '@/lib/sync/core/constants';
-import {
- createDefaultProviderCatalog,
- mergeProviderCatalogs,
- normalizeProviderCatalog,
- readProviderCatalog,
- writeProviderCatalog,
-} from '@/lib/sync/core/providerCatalog';
-import { getSyncContext } from '@/lib/sync/core/providerGateway';
-import {
- buildFileName,
- findRemoteProjectById,
-} from '@/lib/sync/core/remoteCatalog';
-import {
- applyProjectSnapshot,
- applyProjectSnapshotAsNew,
- buildProjectSnapshot,
-} from '@/lib/sync/snapshot';
-import {
- acknowledgeSyncTombstone,
- clearSyncProject,
- getSyncProject,
- hasActiveTombstone,
- listSyncTombstones,
- upsertSyncCatalogState,
- upsertSyncProject,
- upsertSyncTombstone,
-} from '@/lib/sync/syncStorage';
-import { formatBytesToMib, getUtf8ByteLength } from '@/lib/sync/payloadBudget';
-
-function parseEncryptedPayloadJson(encryptedText, contextLabel = 'sync payload') {
- if (typeof encryptedText !== 'string') {
- throw new Error(`Invalid ${contextLabel}: expected text payload`);
- }
-
- const payloadBytes = new TextEncoder().encode(encryptedText).length;
- if (payloadBytes > MAX_SYNC_PAYLOAD_BYTES) {
- throw new Error(`Refusing oversized ${contextLabel}`);
- }
-
- let payload;
- try {
- payload = JSON.parse(encryptedText);
- } catch (error) {
- throw new Error(`Invalid ${contextLabel}: malformed JSON`);
- }
-
- if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
- throw new Error(`Invalid ${contextLabel}: expected object`);
- }
-
- return payload;
-}
-
-function summarizeSnapshot(snapshot) {
- const project = snapshot?.project || {};
- const dialogues = snapshot?.dialogues || [];
- return {
- projectId: project?.id || '',
- name: project?.name || '',
- version: project?.version || '',
- createdAt: project?.createdAt || '',
- modifiedAt: project?.modifiedAt || '',
- dialogueCount: dialogues.length,
- participantCount: snapshot?.participants?.length || 0,
- categoryCount: snapshot?.categories?.length || 0,
- decoratorCount: snapshot?.decorators?.length || 0,
- localizedStringCount: snapshot?.localizedStrings?.length || 0,
- nodeCount: snapshot?.nodes?.length || 0,
- edgeCount: snapshot?.edges?.length || 0,
- dialogueNames: dialogues.slice(0, 5).map((dialogue) => dialogue?.name || '').filter(Boolean),
- };
-}
-
-function toRevision(value) {
- const revision = Number(value || 0);
- if (Number.isNaN(revision) || revision < 0) return 0;
- return Math.floor(revision);
-}
-
-function toTimeMs(value) {
- const parsed = Date.parse(value || '');
- return Number.isNaN(parsed) ? 0 : parsed;
-}
-
-function resolvePassphraseForProvider(providerId, passphrase = '', passphrases = null) {
- if (passphrases && typeof passphrases === 'object') {
- const providerPassphrase = String(passphrases?.[providerId] || '').trim();
- if (providerPassphrase) return providerPassphrase;
- }
- return String(passphrase || '').trim();
-}
-
-function normalizeProviders(provider, providers = []) {
- const all = [];
- for (const entry of Array.isArray(providers) ? providers : []) {
- const value = String(entry || '').trim();
- if (value) all.push(value);
- }
- const providerId = String(provider || '').trim();
- if (providerId) all.push(providerId);
- return Array.from(new Set(all));
-}
-
-async function markProjectSyncTimestamp(projectId, syncTimestamp = '') {
- const normalizedProjectId = String(projectId || '').trim();
- if (!normalizedProjectId) return;
- const timestamp = syncTimestamp || new Date().toISOString();
- await db.projects.update(normalizedProjectId, { syncTimestamp: timestamp });
- const dialogues = await db.dialogues.where('projectId').equals(normalizedProjectId).toArray();
- if (!dialogues.length) return;
- await db.dialogues.bulkPut(
- dialogues.map((dialogue) => ({
- ...dialogue,
- syncTimestamp: timestamp,
- }))
- );
-}
-
-async function deleteLocalDialogueRecords(dialogueId) {
- const normalizedDialogueId = String(dialogueId || '').trim();
- if (!normalizedDialogueId) return { deleted: false, reason: 'invalid-dialogue-id' };
- let deleted = false;
- await db.transaction('rw', [db.dialogues, db.nodes, db.edges, db.localizedStrings], async () => {
- const existing = await db.dialogues.get(normalizedDialogueId);
- if (!existing) return;
- deleted = true;
- await db.dialogues.delete(normalizedDialogueId);
- await db.nodes.where('dialogueId').equals(normalizedDialogueId).delete();
- await db.edges.where('dialogueId').equals(normalizedDialogueId).delete();
- await db.localizedStrings.where('dialogueId').equals(normalizedDialogueId).delete();
- });
- return { deleted, dialogueId: normalizedDialogueId };
-}
-
-async function deleteLocalProjectRecords(projectId, providerId = '') {
- const normalizedProjectId = String(projectId || '').trim();
- if (!normalizedProjectId) return { deleted: false, reason: 'invalid-project-id' };
-
- let deleted = false;
- await db.transaction(
- 'rw',
- [
- db.projects,
- db.dialogues,
- db.participants,
- db.categories,
- db.decorators,
- db.conditions,
- db.localizedStrings,
- db.nodes,
- db.edges,
- db.syncProjects,
- ],
- async () => {
- const existingProject = await db.projects.get(normalizedProjectId);
- deleted = Boolean(existingProject);
- const projectDialogues = await db.dialogues.where('projectId').equals(normalizedProjectId).toArray();
- const dialogueIds = projectDialogues.map((dialogue) => dialogue.id);
-
- if (dialogueIds.length > 0) {
- await db.nodes.where('dialogueId').anyOf(dialogueIds).delete();
- await db.edges.where('dialogueId').anyOf(dialogueIds).delete();
- }
-
- await db.projects.delete(normalizedProjectId);
- await db.dialogues.where('projectId').equals(normalizedProjectId).delete();
- await db.participants.where('projectId').equals(normalizedProjectId).delete();
- await db.categories.where('projectId').equals(normalizedProjectId).delete();
- await db.decorators.where('projectId').equals(normalizedProjectId).delete();
- await db.conditions.where('projectId').equals(normalizedProjectId).delete();
- await db.localizedStrings.where('projectId').equals(normalizedProjectId).delete();
- if (providerId) {
- await db.syncProjects.delete([normalizedProjectId, providerId]);
- } else {
- await db.syncProjects.where('projectId').equals(normalizedProjectId).delete();
- }
- }
- );
-
- return { deleted, projectId: normalizedProjectId };
-}
-
-function buildTombstonePayload({ entityType, entityId, projectId = '', deletedAt = '', expiresAt = '' }) {
- const normalizedEntityType = String(entityType || '').trim().toLowerCase();
- if (normalizedEntityType !== 'project' && normalizedEntityType !== 'dialogue') {
- throw new Error('Invalid tombstone entity type');
- }
- const normalizedEntityId = String(entityId || '').trim();
- if (!normalizedEntityId) {
- throw new Error('Invalid tombstone entity id');
- }
- const nowIso = new Date().toISOString();
- const normalizedDeletedAt = deletedAt && !Number.isNaN(Date.parse(deletedAt)) ? deletedAt : nowIso;
- const normalizedExpiresAt =
- expiresAt && !Number.isNaN(Date.parse(expiresAt))
- ? expiresAt
- : new Date(toTimeMs(normalizedDeletedAt) + SYNC_TOMBSTONE_TTL_MS).toISOString();
- return {
- entityType: normalizedEntityType,
- entityId: normalizedEntityId,
- projectId: String(projectId || '').trim() || '',
- deletedAt: normalizedDeletedAt,
- expiresAt: normalizedExpiresAt,
- };
-}
-
-function upsertCatalogTombstone(catalog, tombstone) {
- const normalizedCatalog = normalizeProviderCatalog(catalog || createDefaultProviderCatalog());
- const key = `${tombstone.entityType}:${tombstone.entityId}`;
- const tombstoneMap = new Map((normalizedCatalog.tombstones || []).map((entry) => [`${entry.entityType}:${entry.entityId}`, entry]));
- const existing = tombstoneMap.get(key);
- if (!existing || toTimeMs(tombstone.deletedAt) >= toTimeMs(existing.deletedAt)) {
- tombstoneMap.set(key, tombstone);
- }
- return {
- ...normalizedCatalog,
- tombstones: Array.from(tombstoneMap.values()),
- };
-}
-
-function setProjectInCatalog(catalog, { projectId, snapshotFileId, snapshotRevision, updatedAt, dialogueIds = [] }) {
- const normalizedCatalog = normalizeProviderCatalog(catalog || createDefaultProviderCatalog());
- const nextProjects = (normalizedCatalog.objects.projects || []).filter(
- (entry) => String(entry?.projectId || '').trim() !== projectId
- );
- nextProjects.push({ projectId, snapshotFileId, snapshotRevision: toRevision(snapshotRevision), updatedAt });
- const nextDialogues = (normalizedCatalog.objects.dialogues || []).filter(
- (entry) => String(entry?.projectId || '').trim() !== projectId
- );
- for (const dialogueId of dialogueIds) {
- nextDialogues.push({ dialogueId, projectId, updatedAt });
- }
- return {
- ...normalizedCatalog,
- objects: {
- projects: nextProjects,
- dialogues: nextDialogues,
- },
- };
-}
-
-async function refreshCatalogState(providerId, catalogRevision = 0, status = 'ok', error = '') {
- await upsertSyncCatalogState(providerId, getActiveProfileId(), {
- catalogRevision,
- fetchedAt: new Date().toISOString(),
- status,
- error,
- });
-}
-
-export async function previewPullFromFile({ projectId, fileId, revision, passphrase, provider }) {
- const { storage } = getSyncContext({ provider });
- const encryptedText = await storage.downloadFile(fileId);
- const payload = parseEncryptedPayloadJson(encryptedText, 'remote sync payload');
- const snapshot = await decryptPayload(passphrase, payload);
- return {
- projectId,
- fileId,
- revision: revision ?? 0,
- summary: summarizeSnapshot(snapshot),
- };
-}
-
-export async function previewPushProject({ projectId }) {
- const snapshot = await buildProjectSnapshot(projectId);
- return {
- projectId,
- summary: summarizeSnapshot(snapshot),
- };
-}
-
-export async function pullProjectFromFile({ projectId, fileId, revision, passphrase, provider }) {
- const { providerId, storage } = getSyncContext({ provider });
- const encryptedText = await storage.downloadFile(fileId);
- const payload = parseEncryptedPayloadJson(encryptedText, 'remote sync payload');
- const snapshot = await decryptPayload(passphrase, payload);
- await applyProjectSnapshot(snapshot);
- const nowIso = new Date().toISOString();
- await markProjectSyncTimestamp(projectId, nowIso);
- await upsertSyncProject(projectId, providerId, {
- revision: revision ?? 0,
- remoteFileId: fileId,
- lastSyncedAt: nowIso,
- });
- return { pulled: true, revision: revision ?? 0 };
-}
-
-export async function pullProject({ projectId, passphrase, onProgress, provider }) {
- const { providerId, storage } = getSyncContext({ provider });
- onProgress?.('checking', 20);
- const remoteFile = await findRemoteProjectById(projectId, { provider: providerId });
- if (!remoteFile) {
- return { pulled: false, reason: 'missing' };
- }
- onProgress?.('downloading', 45);
- const encryptedText = await storage.downloadFile(remoteFile.fileId);
- const payload = parseEncryptedPayloadJson(encryptedText, 'remote sync payload');
- onProgress?.('decrypting', 70);
- const snapshot = await decryptPayload(passphrase, payload);
- onProgress?.('applying', 90);
- await applyProjectSnapshot(snapshot);
- const nowIso = new Date().toISOString();
- await markProjectSyncTimestamp(projectId, nowIso);
- const remoteRevision = Number(remoteFile.revision || 0);
- await upsertSyncProject(projectId, providerId, {
- revision: remoteRevision,
- remoteFileId: remoteFile.fileId,
- lastSyncedAt: nowIso,
- });
- return { pulled: true, revision: remoteRevision };
-}
-
-export async function pullProjectAsNew({ projectId, passphrase, fileId, revision, onProgress, provider }) {
- const { storage } = getSyncContext({ provider });
- const fileName = buildFileName(projectId);
- onProgress?.('checking', 20);
- const remoteFile = fileId ? { id: fileId } : await storage.findFileByName(fileName);
- if (!remoteFile) return { pulled: false, reason: 'missing' };
- onProgress?.('downloading', 45);
- const encryptedText = await storage.downloadFile(remoteFile.id);
- const payload = parseEncryptedPayloadJson(encryptedText, 'remote sync payload');
- onProgress?.('decrypting', 70);
- const snapshot = await decryptPayload(passphrase, payload);
- onProgress?.('applying', 90);
- const newProjectId = await applyProjectSnapshotAsNew(snapshot);
- return { pulled: true, sourceProjectId: projectId, newProjectId, revision: revision ?? 0 };
-}
-export async function pushProject({ projectId, passphrase, provider, mergedTombstones = [] }) {
- const { providerId, storage } = getSyncContext({ provider });
- const normalizedProjectId = String(projectId || '').trim();
- if (!normalizedProjectId) {
- throw new Error('Invalid project id');
- }
-
- const tombstonedInMerge = mergedTombstones.some(
- (entry) =>
- entry?.entityType === 'project' &&
- String(entry?.entityId || '').trim() === normalizedProjectId &&
- !(entry?.expiresAt && Date.parse(entry.expiresAt) <= Date.now())
- );
- if (tombstonedInMerge) {
- throw new Error(`Push blocked: project ${normalizedProjectId} is tombstoned`);
- }
-
- const hasLocalProjectTombstone = await hasActiveTombstone({
- entityType: 'project',
- entityId: normalizedProjectId,
- });
- if (hasLocalProjectTombstone) {
- throw new Error(`Push blocked: project ${normalizedProjectId} is tombstoned`);
- }
-
- const snapshot = await buildProjectSnapshot(normalizedProjectId);
- const snapshotDialogueIds = Array.isArray(snapshot?.dialogues)
- ? snapshot.dialogues.map((dialogue) => String(dialogue?.id || '').trim()).filter(Boolean)
- : [];
- for (const dialogueId of snapshotDialogueIds) {
- const hasDialogueTombstone = await hasActiveTombstone({
- entityType: 'dialogue',
- entityId: dialogueId,
- });
- if (hasDialogueTombstone) {
- throw new Error(`Push blocked: dialogue ${dialogueId} is tombstoned`);
- }
- }
-
- const encryptedPayload = await encryptPayload(passphrase, snapshot);
- const content = JSON.stringify(encryptedPayload);
- const contentBytes = getUtf8ByteLength(content);
- if (contentBytes > MAX_SYNC_PAYLOAD_BYTES) {
- throw new Error(`Sync payload is too large (${formatBytesToMib(contentBytes)} MiB > ${(MAX_SYNC_PAYLOAD_BYTES / (1024 * 1024)).toFixed(0)} MiB limit).`);
- }
- const fileName = buildFileName(normalizedProjectId);
- const remoteFile = await findRemoteProjectById(normalizedProjectId, { provider: providerId });
- const localMeta = await getSyncProject(normalizedProjectId, providerId);
- const localRevision = toRevision(localMeta?.revision);
- const nextRevision = localRevision + 1;
- const nowIso = new Date().toISOString();
-
- const appProperties = {
- revision: String(nextRevision),
- projectId: normalizedProjectId,
- profileId: String(getActiveProfileId() || 'local'),
- schemaVersion: '1',
- updatedAt: nowIso,
- };
-
- let result;
- if (remoteFile?.fileId) {
- result = await storage.updateFile({
- fileId: remoteFile.fileId,
- content,
- mimeType: MIME_TYPE,
- appProperties,
- });
- } else {
- result = await storage.createFile({
- name: fileName,
- content,
- mimeType: MIME_TYPE,
- appProperties,
- });
- }
-
- const remoteFileId = String(result?.id || remoteFile?.fileId || '').trim();
- await upsertSyncProject(normalizedProjectId, providerId, {
- revision: nextRevision,
- remoteFileId,
- lastSyncedAt: nowIso,
- });
- await markProjectSyncTimestamp(normalizedProjectId, nowIso);
-
- const readResult = await readProviderCatalog(providerId);
- const nextCatalog = setProjectInCatalog(readResult?.catalog, {
- projectId: normalizedProjectId,
- snapshotFileId: remoteFileId,
- snapshotRevision: nextRevision,
- updatedAt: nowIso,
- dialogueIds: snapshotDialogueIds,
- });
- const writeResult = await writeProviderCatalog(providerId, nextCatalog, {
- fileId: readResult?.fileId || '',
- });
- await refreshCatalogState(providerId, toRevision(writeResult.catalog?.catalogRevision), 'ok', '');
-
- return { pushed: true, revision: nextRevision, providerId };
-}
-
-export async function publishTombstone({ provider, entityType, entityId, projectId = '', deletedAt = '', expiresAt = '' }) {
- const { providerId, storage } = getSyncContext({ provider });
- const tombstone = buildTombstonePayload({ entityType, entityId, projectId, deletedAt, expiresAt });
- const readResult = await readProviderCatalog(providerId);
- if (!readResult?.readable) {
- throw new Error(`Provider ${providerId} catalog is unavailable`);
- }
-
- let nextCatalog = normalizeProviderCatalog(readResult.catalog);
- const existingProject = (nextCatalog.objects.projects || []).find(
- (entry) => String(entry?.projectId || '').trim() === String(tombstone.entityId || '').trim()
- );
- nextCatalog = upsertCatalogTombstone(nextCatalog, tombstone);
- if (tombstone.entityType === 'project') {
- nextCatalog = {
- ...nextCatalog,
- objects: {
- projects: (nextCatalog.objects.projects || []).filter(
- (entry) => String(entry?.projectId || '').trim() !== tombstone.entityId
- ),
- dialogues: (nextCatalog.objects.dialogues || []).filter(
- (entry) => String(entry?.projectId || '').trim() !== tombstone.entityId
- ),
- },
- };
- }
- if (tombstone.entityType === 'dialogue') {
- nextCatalog = {
- ...nextCatalog,
- objects: {
- ...nextCatalog.objects,
- dialogues: (nextCatalog.objects.dialogues || []).filter(
- (entry) => String(entry?.dialogueId || '').trim() !== tombstone.entityId
- ),
- },
- };
- }
-
- const writeResult = await writeProviderCatalog(providerId, nextCatalog, {
- fileId: readResult.fileId || '',
- });
- await refreshCatalogState(providerId, toRevision(writeResult.catalog?.catalogRevision), 'ok', '');
-
- if (tombstone.entityType === 'project') {
- let fileIdToDelete = String(existingProject?.snapshotFileId || '').trim();
- if (!fileIdToDelete) {
- const remote = await findRemoteProjectById(tombstone.entityId, { provider: providerId });
- fileIdToDelete = String(remote?.fileId || '').trim();
- }
- if (fileIdToDelete && typeof storage.deleteFile === 'function') {
- await storage.deleteFile(fileIdToDelete);
- }
- await clearSyncProject(tombstone.entityId, providerId);
- }
-
- await upsertSyncTombstone({
- provider: providerId,
- entityType: tombstone.entityType,
- entityId: tombstone.entityId,
- projectId: tombstone.projectId,
- deletedAt: tombstone.deletedAt,
- expiresAt: tombstone.expiresAt,
- pending: false,
- acknowledgedAt: new Date().toISOString(),
- });
- await acknowledgeSyncTombstone({
- provider: providerId,
- entityType: tombstone.entityType,
- entityId: tombstone.entityId,
- });
-
- return { published: true, providerId, tombstone };
-}
-
-export async function applyMergedTombstones({ tombstones = [], fallbackProviders = [] } = {}) {
- const nowMs = Date.now();
- let deletedProjects = 0;
- let deletedDialogues = 0;
- for (const entry of tombstones) {
- const entityType = String(entry?.entityType || '').trim().toLowerCase();
- const entityId = String(entry?.entityId || '').trim();
- if (!entityId) continue;
- const expiresAtMs = entry?.expiresAt ? Date.parse(entry.expiresAt) : 0;
- if (expiresAtMs && expiresAtMs <= nowMs) continue;
-
- if (entityType === 'project') {
- const result = await deleteLocalProjectRecords(entityId);
- if (result?.deleted) deletedProjects += 1;
- } else if (entityType === 'dialogue') {
- const result = await deleteLocalDialogueRecords(entityId);
- if (result?.deleted) deletedDialogues += 1;
- } else {
- continue;
- }
-
- const providers = Array.from(
- new Set([
- ...(Array.isArray(entry?.sourceProviders) ? entry.sourceProviders : []),
- ...fallbackProviders,
- ].filter(Boolean))
- );
- for (const providerId of providers) {
- await upsertSyncTombstone({
- provider: providerId,
- entityType,
- entityId,
- projectId: String(entry?.projectId || '').trim() || '',
- deletedAt: entry?.deletedAt,
- expiresAt: entry?.expiresAt,
- pending: false,
- acknowledgedAt: new Date().toISOString(),
- });
- }
- }
- return { deletedProjects, deletedDialogues };
-}
-
-export async function gcExpiredTombstones({ providers = [] } = {}) {
- const providerIds = normalizeProviders('', providers);
- const nowMs = Date.now();
- const localRows = await db.syncTombstones.toArray();
- const expiredRows = localRows.filter((row) => row?.expiresAt && Date.parse(row.expiresAt) <= nowMs && !row.pending);
- if (expiredRows.length > 0) {
- await db.syncTombstones.bulkDelete(
- expiredRows.map((row) => [row.provider, row.entityType, row.entityId])
- );
- }
-
- let catalogPurged = 0;
- for (const providerId of providerIds) {
- const readResult = await readProviderCatalog(providerId);
- if (!readResult?.readable) continue;
- const catalog = normalizeProviderCatalog(readResult.catalog);
- const nextTombstones = (catalog.tombstones || []).filter((entry) => !(entry?.expiresAt && Date.parse(entry.expiresAt) <= nowMs));
- if (nextTombstones.length === (catalog.tombstones || []).length) continue;
- catalogPurged += (catalog.tombstones || []).length - nextTombstones.length;
- await writeProviderCatalog(providerId, {
- ...catalog,
- tombstones: nextTombstones,
- }, {
- fileId: readResult.fileId || '',
- });
- }
-
- return { localPurged: expiredRows.length, catalogPurged };
-}
-
-export async function deleteRemoteProject({ projectId, provider }) {
- const { providerId, storage } = getSyncContext({ provider });
- if (typeof storage.deleteFile !== 'function') {
- throw new Error(`Sync provider "${providerId}" does not support remote deletion`);
- }
-
- const remoteFile = await findRemoteProjectById(projectId, { provider: providerId });
- if (!remoteFile?.fileId) {
- await clearSyncProject(projectId, providerId);
- return { deleted: false, reason: 'missing' };
- }
-
- await storage.deleteFile(remoteFile.fileId);
- await clearSyncProject(projectId, providerId);
- return { deleted: true, fileId: remoteFile.fileId };
-}
-
-export async function deleteLocalProject({ projectId, provider }) {
- const { providerId } = getSyncContext({ provider });
- return await deleteLocalProjectRecords(projectId, providerId);
-}
-
-export async function syncAllProjects({
- passphrase,
- passphrases = null,
- onProgress,
- provider,
- providers = [],
- mode = 'full',
-}) {
- const providerIds = normalizeProviders(provider, providers);
- if (!providerIds.length) {
- throw new Error('No sync provider available');
- }
-
- const readResults = await Promise.all(providerIds.map(async (providerId) => await readProviderCatalog(providerId)));
- for (const readResult of readResults) {
- if (readResult?.readable) {
- await refreshCatalogState(readResult.providerId, toRevision(readResult.catalog?.catalogRevision), 'ok', '');
- } else {
- await refreshCatalogState(readResult.providerId, 0, 'unavailable', String(readResult?.error || 'catalog-read-failed'));
- }
- }
- const readableCatalogs = readResults.filter((entry) => entry?.readable);
- if (!readableCatalogs.length) {
- return {
- mode,
- providers: providerIds,
- readableProviders: [],
- skippedDestructive: true,
- remoteCount: 0,
- localCount: await db.projects.count(),
- };
- }
-
- const mergedCatalog = mergeProviderCatalogs(readableCatalogs);
- const readableProviders = readableCatalogs.map((entry) => entry.providerId);
- await applyMergedTombstones({
- tombstones: mergedCatalog.tombstones,
- fallbackProviders: readableProviders,
- });
-
- const remoteProjects = Array.isArray(mergedCatalog.objects?.projects) ? mergedCatalog.objects.projects : [];
- const remoteMap = new Map(remoteProjects.map((entry) => [entry.projectId, entry]));
- const localProjects = await db.projects.toArray();
- const localMap = new Map(localProjects.map((entry) => [entry.id, entry]));
- const allSyncMeta = await db.syncProjects.toArray();
- const metaMap = new Map(allSyncMeta.map((entry) => [`${entry.projectId}::${entry.provider}`, entry]));
- const localProjectTombstones = await listSyncTombstones({
- entityType: 'project',
- includeExpired: false,
- });
- const localProjectTombstoneProviders = new Map();
- for (const entry of localProjectTombstones) {
- const projectId = String(entry?.entityId || '').trim();
- const providerId = String(entry?.provider || '').trim();
- if (!projectId || !providerId) continue;
- if (!localProjectTombstoneProviders.has(projectId)) {
- localProjectTombstoneProviders.set(projectId, new Set());
- }
- localProjectTombstoneProviders.get(projectId).add(providerId);
- }
- const toPull = [];
- const toPush = [];
- for (const remote of remoteProjects) {
- if (mode === 'push') continue;
- const projectId = String(remote?.projectId || '').trim();
- const sourceProvider = String(remote?.sourceProvider || '').trim();
- if (!projectId || !sourceProvider) continue;
- const localTombstoneProviders = localProjectTombstoneProviders.get(projectId);
- if (localTombstoneProviders?.has(sourceProvider)) {
- continue;
- }
- const local = localMap.get(projectId);
- const meta = metaMap.get(`${projectId}::${sourceProvider}`) || null;
- const localRevision = toRevision(meta?.revision);
- const remoteRevision = toRevision(remote?.snapshotRevision);
- if (!local || remoteRevision > localRevision || (!meta && toTimeMs(remote.updatedAt) > toTimeMs(local?.syncTimestamp))) {
- toPull.push({
- projectId,
- providerId: sourceProvider,
- fileId: String(remote?.snapshotFileId || '').trim(),
- revision: remoteRevision,
- });
- }
- }
-
- for (const local of localProjects) {
- if (mode === 'pull') continue;
- const projectId = String(local?.id || '').trim();
- if (!projectId) continue;
- const remote = remoteMap.get(projectId);
- if (!remote) {
- const hasAnyMeta = allSyncMeta.some((entry) => String(entry?.projectId || '').trim() === projectId);
- if (!hasAnyMeta && !local?.syncTimestamp) {
- toPush.push({ projectId, providerId: providerIds[0] });
- }
- continue;
- }
-
- const sourceProvider = String(remote?.sourceProvider || providerIds[0] || '').trim();
- if (!sourceProvider) continue;
- const meta = metaMap.get(`${projectId}::${sourceProvider}`) || null;
- const remoteRevision = toRevision(remote?.snapshotRevision);
- const localRevision = toRevision(meta?.revision);
- const hasUnsyncedChanges = toTimeMs(local?.modifiedAt) > toTimeMs(meta?.lastSyncedAt || local?.syncTimestamp) + 1000;
- if (!meta || localRevision > remoteRevision || hasUnsyncedChanges) {
- toPush.push({ projectId, providerId: sourceProvider });
- }
- }
-
- const total = toPull.length + toPush.length;
- onProgress?.({
- phase: 'start',
- mode,
- total,
- remoteCount: remoteProjects.length,
- localCount: localProjects.length,
- });
-
- let index = 0;
- for (const action of toPull) {
- index += 1;
- onProgress?.({ phase: 'pull', projectId: action.projectId, providerId: action.providerId, index, total });
- if (!action.fileId) continue;
- const providerPassphrase = resolvePassphraseForProvider(action.providerId, passphrase, passphrases);
- if (!providerPassphrase) {
- console.warn('[sync] Missing passphrase, skipping pull for provider', action.providerId);
- continue;
- }
- await pullProjectFromFile({
- projectId: action.projectId,
- fileId: action.fileId,
- revision: action.revision,
- passphrase: providerPassphrase,
- provider: action.providerId,
- });
- }
-
- for (const action of toPush) {
- index += 1;
- onProgress?.({ phase: 'push', projectId: action.projectId, providerId: action.providerId, index, total });
- const providerPassphrase = resolvePassphraseForProvider(action.providerId, passphrase, passphrases);
- if (!providerPassphrase) {
- console.warn('[sync] Missing passphrase, skipping push for provider', action.providerId);
- continue;
- }
- await pushProject({
- projectId: action.projectId,
- passphrase: providerPassphrase,
- provider: action.providerId,
- mergedTombstones: mergedCatalog.tombstones,
- });
- }
-
- await gcExpiredTombstones({ providers: readableProviders });
-
- return {
- mode,
- providers: providerIds,
- readableProviders,
- remoteCount: remoteProjects.length,
- localCount: localProjects.length,
- toPull: toPull.length,
- toPush: toPush.length,
- pendingTombstones: (await listSyncTombstones({ pendingOnly: true, includeExpired: false })).length,
- };
-}
-
-
-
-
+/** Compatibility entry points route through immutable revision synchronization.
+ * Legacy mutable snapshots/catalogues are read-only migration inputs. */
+import { getRepositoryContext } from '@/lib/db';
+import { mutateProject, readProjectState } from '@/lib/persistence/projectRepository';
+import { syncRevisions, inspectRevisions, completeAncestry } from './revisionProtocol';
+import { applyProjectSnapshotAsNew } from '@/lib/sync/snapshot';
+import { upsertSyncTombstone } from '@/lib/sync/syncStorage';
+
+export const pushProject = options => syncRevisions({ ...options, mode: 'push' });
+export const pullProject = options => syncRevisions({ ...options, mode: 'pull' });
+export const pullProjectFromFile = options => pullProject(options);
+export const syncAllProjects = options => syncRevisions(options);
+export const previewPushProject = async ({ projectId, context }) => readProjectState(projectId, context);
+export const previewPullFromFile = options => inspectRevisions(options);
+export async function pullProjectAsNew(options = {}) {
+ const inspected = await inspectRevisions(options);
+ const candidates = [...inspected.remoteIds].map(id => inspected.revisions.get(id)).filter(row => row.projectId === options.projectId && (!options.fileId || inspected.fileIds.get(row.id) === options.fileId));
+ if (candidates.length !== 1 || !completeAncestry(candidates[0].id, inspected.revisions)) throw new Error('Select one complete revision before copying this project.');
+ if (candidates[0].operation === 'delete') throw new Error('A deletion revision has no project to copy.');
+ return applyProjectSnapshotAsNew(candidates[0].snapshot, { context: inspected.options.context });
+}
+export async function deleteLocalProject({ projectId, context: suppliedContext } = {}) {
+ const context = suppliedContext || await getRepositoryContext();
+ if (!await context.db.projects.get(projectId)) return;
+ return mutateProject(projectId, { context, operation: 'delete' });
+}
+export async function deleteRemoteProject(options = {}) {
+ await deleteLocalProject(options);
+ return pushProject(options);
+}
+export async function publishTombstone(options = {}) {
+ await upsertSyncTombstone({ ...options, pending: true });
+ return pushProject(options);
+}
+export const applyMergedTombstones = options => syncRevisions({ ...options, mode: 'pull' });
+// Deletion observations remain durable; no age-based garbage collection.
+export const gcExpiredTombstones = async () => ({ removed: 0 });
diff --git a/src/lib/sync/credentialStore.js b/src/lib/sync/credentialStore.js
new file mode 100644
index 0000000..1537783
--- /dev/null
+++ b/src/lib/sync/credentialStore.js
@@ -0,0 +1,79 @@
+import { getRepositoryContext } from '@/lib/db';
+
+// Secrets never enter Zustand persistence or IndexedDB. The desktop vault is
+// the only persistent destination; a browser session uses this process memory.
+const sessionSecrets = new Map();
+const bridge = () => globalThis.window?.electronAPI;
+const keyFor = (context, key) => `${context.profileId}:${key}`;
+
+export async function credentialCapabilities() {
+ const api = bridge();
+ return api?.isElectron && api.credentialStatus ? api.credentialStatus() : { canRemember: false };
+}
+
+export async function readSecret(key, context = null) {
+ context ||= await getRepositoryContext();
+ const cached = sessionSecrets.get(keyFor(context, key));
+ if (cached !== undefined) { context.assertCurrent(); return cached; }
+ const api = bridge();
+ const value = api?.isElectron && api.getCredential ? await api.getCredential({ profileId: context.profileId, key }) : null;
+ context.assertCurrent();
+ if (value !== null) sessionSecrets.set(keyFor(context, key), value);
+ return value;
+}
+
+export async function writeSecret(key, value, { remember = false, context = null } = {}) {
+ context ||= await getRepositoryContext();
+ context.assertCurrent();
+ const api = bridge();
+ if (remember) {
+ if (!(await credentialCapabilities()).canRemember) throw new Error('Secure credential storage is unavailable');
+ context.assertCurrent();
+ await api.setCredential({ profileId: context.profileId, key, value });
+ } else if (api?.isElectron && api.removeCredential) {
+ await api.removeCredential({ profileId: context.profileId, key });
+ }
+ context.assertCurrent();
+ sessionSecrets.set(keyFor(context, key), value);
+}
+
+export async function removeSecret(key, context = null) {
+ context ||= await getRepositoryContext();
+ context.assertCurrent();
+ const api = bridge();
+ if (api?.isElectron && api.removeCredential) await api.removeCredential({ profileId: context.profileId, key });
+ context.assertCurrent();
+ sessionSecrets.delete(keyFor(context, key));
+}
+
+export async function readAccount(provider, context = null) {
+ context ||= await getRepositoryContext();
+ const stored = await readSecret(`${provider}-account`, context);
+ if (stored) return JSON.parse(stored);
+ // Migrate only the active generation. The historical source database remains
+ // untouched as recovery evidence. Browser users explicitly authenticate again.
+ const legacy = await context.db.syncAccounts.get(provider);
+ context.assertCurrent();
+ if (!legacy || !(await credentialCapabilities()).canRemember) return null;
+ await writeSecret(`${provider}-account`, JSON.stringify(legacy), { remember: true, context });
+ context.assertCurrent();
+ await context.db.syncAccounts.delete(provider);
+ return legacy;
+}
+
+export async function writeAccount(provider, data, context = null) {
+ context ||= await getRepositoryContext();
+ const payload = { provider, ...data };
+ await writeSecret(`${provider}-account`, JSON.stringify(payload), { remember: (await credentialCapabilities()).canRemember, context });
+ context.assertCurrent();
+ await context.db.syncAccounts.delete(provider);
+ return payload;
+}
+
+export async function removeAccount(provider, context = null) {
+ context ||= await getRepositoryContext();
+ await removeSecret(`${provider}-account`, context);
+ await removeSecret(`${provider}-passphrase`, context);
+ context.assertCurrent();
+ await context.db.syncAccounts.delete(provider);
+}
diff --git a/src/lib/sync/crypto.js b/src/lib/sync/crypto.js
index c60bf88..8ee3467 100644
--- a/src/lib/sync/crypto.js
+++ b/src/lib/sync/crypto.js
@@ -44,6 +44,7 @@ async function deriveKey(passphrase, salt) {
}
export async function encryptPayload(passphrase, data) {
+ if (typeof passphrase !== 'string' || !passphrase.trim()) throw new Error('Encryption passphrase is required');
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(passphrase, salt);
@@ -64,13 +65,14 @@ export async function encryptPayload(passphrase, data) {
}
export async function decryptPayload(passphrase, payload) {
- if (!payload?.salt || !payload?.iv || !payload?.ciphertext) {
+ if (payload?.version !== 1 || typeof passphrase !== 'string' || !passphrase.trim() || !['salt', 'iv', 'ciphertext'].every((field) => typeof payload[field] === 'string' && /^[A-Za-z0-9_-]+$/.test(payload[field])) || payload.ciphertext.length > 32 * 1024 * 1024) {
throw new Error('Invalid encrypted payload');
}
const salt = fromBase64Url(payload.salt);
const iv = fromBase64Url(payload.iv);
const ciphertext = fromBase64Url(payload.ciphertext);
+ if (salt.byteLength !== 16 || iv.byteLength !== 12 || ciphertext.byteLength < 16) throw new Error('Invalid encrypted payload parameters');
const key = await deriveKey(passphrase, salt);
const plainBuffer = await crypto.subtle.decrypt(
diff --git a/src/lib/sync/googleDriveAuth.js b/src/lib/sync/googleDriveAuth.js
index 44d0c25..38395d8 100644
--- a/src/lib/sync/googleDriveAuth.js
+++ b/src/lib/sync/googleDriveAuth.js
@@ -87,38 +87,50 @@ function openPopup(url) {
);
}
-function waitForAuthResult(expectedState) {
+function waitForAuthResult(expectedState, popup, signal) {
return new Promise((resolve, reject) => {
+ let settled = false;
+ let timeout;
+ let closedPoll;
+ const finish = (error, result) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timeout);
+ clearInterval(closedPoll);
+ window.removeEventListener('message', handler);
+ signal?.removeEventListener('abort', abort);
+ try { if (!popup.closed) popup.close(); } catch { /* Already detached. */ }
+ if (error) reject(error); else resolve(result);
+ };
+ const abort = () => finish(new DOMException('Authentication cancelled', 'AbortError'));
const handler = (event) => {
- if (event.origin !== window.location.origin) return;
+ if (event.origin !== window.location.origin || event.source !== popup) return;
const { type, code, state, error, accessToken, expiresIn, tokenType, scope } = event.data || {};
- if (type !== 'GOOGLE_OAUTH_RESULT') return;
- if (state !== expectedState) return;
- window.removeEventListener('message', handler);
- if (error) {
- reject(new Error(error));
- } else {
- resolve({ code, accessToken, expiresIn, tokenType, scope });
- }
+ if (type !== 'GOOGLE_OAUTH_RESULT' || state !== expectedState) return;
+ finish(error ? new Error(String(error)) : null, { code, accessToken, expiresIn, tokenType, scope });
};
window.addEventListener('message', handler);
+ signal?.addEventListener('abort', abort, { once: true });
+ timeout = setTimeout(() => finish(new Error('Authentication timed out after 60 seconds')), 60000);
+ closedPoll = setInterval(() => { if (popup.closed) finish(new Error('Authentication window was closed')); }, 250);
+ if (signal?.aborted) abort();
});
}
-export async function startGoogleDriveAuth() {
+export async function startGoogleDriveAuth({ signal } = {}) {
+ if (signal?.aborted) throw new DOMException('Authentication cancelled', 'AbortError');
const clientId = getClientId();
const scopes = getGoogleDriveScopes();
const electronApi = getElectronApi();
if (electronApi?.isElectron && typeof electronApi.startGoogleOAuth === 'function') {
- const desktopClientSecret =
- import.meta.env.VITE_GOOGLE_CLIENT_SECRET_DESKTOP ||
- import.meta.env.VITE_GOOGLE_CLIENT_SECRET ||
- '';
- const result = await electronApi.startGoogleOAuth({
+ const cancel = () => { electronApi.cancelGoogleOAuth?.().catch(() => {}); };
+ signal?.addEventListener('abort', cancel, { once: true });
+ let result;
+ try { result = await electronApi.startGoogleOAuth({
clientId,
- clientSecret: desktopClientSecret,
scopes,
- });
+ }); } finally { signal?.removeEventListener('abort', cancel); }
+ if (signal?.aborted) throw new DOMException('Authentication cancelled', 'AbortError');
if (!result?.accessToken) {
throw new Error('Missing access token');
@@ -151,12 +163,13 @@ export async function startGoogleDriveAuth() {
const authUrl = `${AUTH_ENDPOINT}?${params.toString()}`;
const popup = openPopup(authUrl);
if (!popup) {
+ clearAuthState();
throw new Error('Popup blocked');
}
let result;
try {
- result = await waitForAuthResult(state);
+ result = await waitForAuthResult(state, popup, signal);
} finally {
clearAuthState();
}
@@ -206,7 +219,7 @@ export async function exchangeCodeForToken({ code, redirectUri, clientId }) {
return await response.json();
}
-export async function refreshAccessToken(refreshToken) {
+export async function refreshAccessToken(refreshToken, { signal } = {}) {
const clientId = getClientId();
const body = new URLSearchParams({
client_id: clientId,
@@ -215,6 +228,7 @@ export async function refreshAccessToken(refreshToken) {
});
const response = await fetch(TOKEN_ENDPOINT, {
+ signal,
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
diff --git a/src/lib/sync/googleDriveClient.js b/src/lib/sync/googleDriveClient.js
index 58a74a2..30dfbb2 100644
--- a/src/lib/sync/googleDriveClient.js
+++ b/src/lib/sync/googleDriveClient.js
@@ -1,13 +1,16 @@
import { getSyncAccount, upsertSyncAccount } from '@/lib/sync/syncStorage';
import { refreshAccessToken } from '@/lib/sync/googleDriveAuth';
import { getGoogleDriveSyncRoot } from '@/lib/sync/googleDriveConfig';
+import { getRepositoryContext } from '@/lib/db';
const DRIVE_FILES_ENDPOINT = 'https://www.googleapis.com/drive/v3/files';
const DRIVE_UPLOAD_ENDPOINT = 'https://www.googleapis.com/upload/drive/v3/files';
const ALLOWED_PREFIXES = [DRIVE_FILES_ENDPOINT, DRIVE_UPLOAD_ENDPOINT];
-async function getValidAccessToken() {
- const account = await getSyncAccount('googleDrive');
+async function getValidAccessToken(context) {
+ context.assertCurrent();
+ const account = await getSyncAccount('googleDrive', context);
+ context.assertCurrent();
if (!account?.accessToken) {
throw new Error('Missing Google Drive token');
}
@@ -21,14 +24,16 @@ async function getValidAccessToken() {
throw new Error('tokenExpired');
}
- const refreshed = await refreshAccessToken(account.refreshToken);
+ const refreshed = await refreshAccessToken(account.refreshToken, { signal: context.signal });
+ context.assertCurrent();
const nextExpiresAt = Date.now() + refreshed.expires_in * 1000;
await upsertSyncAccount('googleDrive', {
...account,
accessToken: refreshed.access_token,
expiresAt: nextExpiresAt,
- });
+ }, context);
+ context.assertCurrent();
return refreshed.access_token;
}
@@ -44,19 +49,23 @@ function buildDriveUrl(base, pathSegment, params) {
return url;
}
-async function driveRequest(url, options = {}) {
+async function driveRequest(url, options, context) {
+ context.assertCurrent();
const urlString = url.toString();
if (!ALLOWED_PREFIXES.some((prefix) => urlString.startsWith(prefix))) {
throw new Error('Blocked non-Google Drive request');
}
- const token = await getValidAccessToken();
+ const token = await getValidAccessToken(context);
+ context.assertCurrent();
const response = await fetch(urlString, {
...options,
+ signal: context.signal,
headers: {
Authorization: `Bearer ${token}`,
...options.headers,
},
});
+ context.assertCurrent();
if (!response.ok) {
throw new Error(`Google Drive request failed: ${response.status}`);
@@ -126,7 +135,8 @@ function appendFileAccessParams(params, syncRoot) {
}
}
-export async function findAppDataFile(fileName) {
+export async function findAppDataFile(fileName, { context } = {}) {
+ context ||= await getRepositoryContext(); context.assertCurrent();
const syncRoot = getGoogleDriveSyncRoot();
const query = buildFindFileQuery(fileName, syncRoot);
@@ -137,12 +147,14 @@ export async function findAppDataFile(fileName) {
appendScopeParams(params, syncRoot);
const url = buildDriveUrl(DRIVE_FILES_ENDPOINT, null, params);
- const response = await driveRequest(url);
+ const response = await driveRequest(url, {}, context);
const data = await response.json();
+ context.assertCurrent();
return data.files?.[0] || null;
}
-export async function listAppDataFiles({ namePrefix } = {}) {
+export async function listAppDataFiles({ namePrefix } = {}, { context } = {}) {
+ context ||= await getRepositoryContext(); context.assertCurrent();
const syncRoot = getGoogleDriveSyncRoot();
const query = buildListFilesQuery(namePrefix, syncRoot);
@@ -161,8 +173,9 @@ export async function listAppDataFiles({ namePrefix } = {}) {
}
const url = buildDriveUrl(DRIVE_FILES_ENDPOINT, null, params);
- const response = await driveRequest(url);
+ const response = await driveRequest(url, {}, context);
const data = await response.json();
+ context.assertCurrent();
if (Array.isArray(data.files)) {
files.push(...data.files);
}
@@ -172,16 +185,18 @@ export async function listAppDataFiles({ namePrefix } = {}) {
return files;
}
-export async function downloadAppDataFile(fileId) {
+export async function downloadAppDataFile(fileId, { context } = {}) {
+ context ||= await getRepositoryContext(); context.assertCurrent();
const syncRoot = getGoogleDriveSyncRoot();
const params = new URLSearchParams({ alt: 'media' });
appendFileAccessParams(params, syncRoot);
const url = buildDriveUrl(DRIVE_FILES_ENDPOINT, fileId, params);
- const response = await driveRequest(url);
- return await response.text();
+ const response = await driveRequest(url, {}, context);
+ const content = await response.text(); context.assertCurrent(); return content;
}
-export async function createAppDataFile({ name, content, mimeType, appProperties }) {
+export async function createAppDataFile({ name, content, mimeType, appProperties }, { context } = {}) {
+ context ||= await getRepositoryContext(); context.assertCurrent();
const syncRoot = getGoogleDriveSyncRoot();
const metadata = {
name,
@@ -202,12 +217,13 @@ export async function createAppDataFile({ name, content, mimeType, appProperties
'Content-Type': `multipart/related; boundary=${boundary}`,
},
body,
- });
+ }, context);
- return await response.json();
+ const result = await response.json(); context.assertCurrent(); return result;
}
-export async function updateAppDataFile({ fileId, content, mimeType, appProperties }) {
+export async function updateAppDataFile({ fileId, content, mimeType, appProperties }, { context } = {}) {
+ context ||= await getRepositoryContext(); context.assertCurrent();
const syncRoot = getGoogleDriveSyncRoot();
const metadata = {
appProperties,
@@ -226,18 +242,19 @@ export async function updateAppDataFile({ fileId, content, mimeType, appProperti
'Content-Type': `multipart/related; boundary=${boundary}`,
},
body,
- });
+ }, context);
- return await response.json();
+ const result = await response.json(); context.assertCurrent(); return result;
}
-export async function deleteAppDataFile(fileId) {
+export async function deleteAppDataFile(fileId, { context } = {}) {
+ context ||= await getRepositoryContext(); context.assertCurrent();
const syncRoot = getGoogleDriveSyncRoot();
const params = new URLSearchParams();
appendFileAccessParams(params, syncRoot);
const url = buildDriveUrl(DRIVE_FILES_ENDPOINT, fileId, params);
await driveRequest(url, {
method: 'DELETE',
- });
+ }, context);
return { id: fileId };
}
diff --git a/src/lib/sync/providers/storageProviders.js b/src/lib/sync/providers/storageProviders.js
index e9ef9f7..102d311 100644
--- a/src/lib/sync/providers/storageProviders.js
+++ b/src/lib/sync/providers/storageProviders.js
@@ -27,12 +27,12 @@ function createUnsupportedProviderError(providerId) {
const googleDriveStorageProvider = Object.freeze({
id: 'googleDrive',
supportsCloudSync: true,
- findFileByName: async (fileName) => await findGoogleAppDataFile(fileName),
- listFiles: async ({ namePrefix } = {}) => await listGoogleAppDataFiles({ namePrefix }),
- downloadFile: async (fileId) => await downloadGoogleAppDataFile(fileId),
- createFile: async (payload) => await createGoogleAppDataFile(payload),
- updateFile: async (payload) => await updateGoogleAppDataFile(payload),
- deleteFile: async (fileId) => await deleteGoogleAppDataFile(fileId),
+ findFileByName: async (fileName, options) => await findGoogleAppDataFile(fileName, options),
+ listFiles: async ({ namePrefix } = {}, options) => await listGoogleAppDataFiles({ namePrefix }, options),
+ downloadFile: async (fileId, options) => await downloadGoogleAppDataFile(fileId, options),
+ createFile: async (payload, options) => await createGoogleAppDataFile(payload, options),
+ updateFile: async (payload, options) => await updateGoogleAppDataFile(payload, options),
+ deleteFile: async (fileId, options) => await deleteGoogleAppDataFile(fileId, options),
});
const steamStorageProvider = Object.freeze({
diff --git a/src/lib/sync/snapshot.js b/src/lib/sync/snapshot.js
index 26772d7..56b03be 100644
--- a/src/lib/sync/snapshot.js
+++ b/src/lib/sync/snapshot.js
@@ -1,333 +1,33 @@
-import { v4 as uuidv4 } from 'uuid';
-import { db } from '@/lib/db';
-import { buildLocalizedStringKey } from '@/lib/localization/stringTable';
-
-function stripLocalOnlyProjectFields(project = {}) {
- const sanitized = { ...(project || {}) };
- delete sanitized.lastExportPath;
- delete sanitized.syncTimestamp;
- return sanitized;
-}
-
-function stripLocalOnlyDialogueFields(dialogue = {}) {
- const sanitized = { ...(dialogue || {}) };
- delete sanitized.lastExportPath;
- delete sanitized.syncTimestamp;
- return sanitized;
-}
-
-function dedupeBy(items = [], keySelector) {
- const latestByKey = new Map();
- for (const item of items) {
- const key = String(keySelector(item) || '').trim();
- if (!key) continue;
- latestByKey.set(key, item);
- }
- return Array.from(latestByKey.values());
-}
-
-export async function buildProjectSnapshot(projectId) {
- const project = await db.projects.get(projectId);
- if (!project) {
- throw new Error('Project not found');
- }
-
- const dialogues = await db.dialogues.where('projectId').equals(projectId).toArray();
- const participants = await db.participants.where('projectId').equals(projectId).toArray();
- const categories = await db.categories.where('projectId').equals(projectId).toArray();
- const decorators = await db.decorators.where('projectId').equals(projectId).toArray();
- const conditions = await db.conditions.where('projectId').equals(projectId).toArray();
- const localizedStrings = await db.localizedStrings
- .where('projectId')
- .equals(projectId)
- .toArray();
-
- const dialogueIds = dialogues.map((dialogue) => dialogue.id);
- const nodes = dialogueIds.length
- ? await db.nodes.where('dialogueId').anyOf(dialogueIds).toArray()
- : [];
- const edges = dialogueIds.length
- ? await db.edges.where('dialogueId').anyOf(dialogueIds).toArray()
- : [];
-
- return {
- version: 2,
- project: stripLocalOnlyProjectFields(project),
- dialogues: dialogues.map((dialogue) => stripLocalOnlyDialogueFields(dialogue)),
- participants,
- categories,
- decorators,
- conditions,
- localizedStrings,
- nodes,
- edges,
- };
+import { getRepositoryContext, assertProjectReady } from '@/lib/db';
+import { canonicalizeProject, requireValidProject } from '@/lib/persistence/canonicalProject';
+import { readProjectState, prepareProjectCommit, commitPreparedProject } from '@/lib/persistence/projectRepository';
+import { remapProjectIdentities } from '@/lib/persistence/projectRemap';
+
+export async function buildProjectSnapshot(projectId, { context: suppliedContext, requireReady = false } = {}) {
+ const context = suppliedContext || await getRepositoryContext();
+ if (requireReady) await assertProjectReady(projectId, context);
+ const { snapshot } = await readProjectState(projectId, context);
+ const canonical = await canonicalizeProject(snapshot, { allowIncompleteMedia: !requireReady });
+ context.assertCurrent();
+ return requireReady ? requireValidProject(canonical) : canonical;
}
-export async function applyProjectSnapshot(snapshot) {
- const {
- project: rawProject,
- dialogues = [],
- participants = [],
- categories = [],
- decorators = [],
- conditions = [],
- localizedStrings = [],
- nodes = [],
- edges = [],
- } = snapshot || {};
- const project = stripLocalOnlyProjectFields(rawProject);
- const sanitizedDialogues = dedupeBy(
- dialogues.map((dialogue) => stripLocalOnlyDialogueFields(dialogue)),
- (dialogue) => dialogue?.id
- );
- const sanitizedParticipants = dedupeBy(participants, (participant) => participant?.id);
- const sanitizedCategories = dedupeBy(categories, (category) => category?.id);
- const sanitizedDecorators = dedupeBy(decorators, (decorator) => decorator?.id);
- const sanitizedConditions = dedupeBy(conditions, (condition) => condition?.id);
- const normalizedLocalizedStrings = localizedStrings.map((entry) => {
- const key =
- entry?.key ||
- buildLocalizedStringKey({
- dialogueId: entry?.dialogueId,
- nodeId: entry?.nodeId,
- rowId: entry?.rowId,
- field: entry?.field,
- });
- return {
- ...entry,
- projectId: project.id,
- key,
- };
+export async function applyProjectSnapshot(snapshot, options = {}) {
+ const context = options.context || await getRepositoryContext();
+ const current = await readProjectState(snapshot?.project?.id, context);
+ const prepared = await prepareProjectCommit(snapshot, {
+ context, expectedSequence: options.expectedSequence ?? current.sequence,
+ operation: options.operation || 'update', parentRevisionIds: options.parentRevisionIds,
+ revisionId: options.revisionId, enqueue: false, remoteRevision: options.remoteRevision,
});
- const sanitizedLocalizedStrings = dedupeBy(
- normalizedLocalizedStrings,
- (entry) => `${project.id}::${entry?.key || ''}`
- );
- const sanitizedNodes = dedupeBy(
- nodes,
- (node) => `${node?.dialogueId || ''}::${node?.id || ''}`
- );
- const sanitizedEdges = dedupeBy(
- edges,
- (edge) => `${edge?.dialogueId || ''}::${edge?.id || ''}`
- );
-
- if (!project?.id) {
- throw new Error('Invalid snapshot');
- }
-
- const syncTimestamp = new Date().toISOString();
- const syncedProject = { ...project, syncTimestamp };
- const syncedDialogues = sanitizedDialogues.map((dialogue) => ({
- ...dialogue,
- syncTimestamp,
- }));
- const incomingDialogueIds = syncedDialogues
- .map((dialogue) => String(dialogue?.id || '').trim())
- .filter(Boolean);
-
- await db.transaction(
- 'rw',
- [
- db.projects,
- db.dialogues,
- db.participants,
- db.categories,
- db.decorators,
- db.conditions,
- db.localizedStrings,
- db.nodes,
- db.edges,
- ],
- async () => {
- if (incomingDialogueIds.length > 0) {
- const conflictingDialogues = await db.dialogues
- .where('id')
- .anyOf(incomingDialogueIds)
- .toArray();
- const conflictingDialogueIds = conflictingDialogues
- .map((dialogue) => String(dialogue?.id || '').trim())
- .filter(Boolean);
- if (conflictingDialogueIds.length > 0) {
- await db.nodes.where('dialogueId').anyOf(conflictingDialogueIds).delete();
- await db.edges.where('dialogueId').anyOf(conflictingDialogueIds).delete();
- await db.localizedStrings.where('dialogueId').anyOf(conflictingDialogueIds).delete();
- await db.dialogues.bulkDelete(conflictingDialogueIds);
- }
- }
-
- const existingDialogues = await db.dialogues.where('projectId').equals(project.id).toArray();
- const existingDialogueIds = existingDialogues.map((dialogue) => dialogue.id);
- if (existingDialogueIds.length > 0) {
- await db.nodes.where('dialogueId').anyOf(existingDialogueIds).delete();
- await db.edges.where('dialogueId').anyOf(existingDialogueIds).delete();
- }
-
- await db.projects.delete(project.id);
- await db.dialogues.where('projectId').equals(project.id).delete();
- await db.participants.where('projectId').equals(project.id).delete();
- await db.categories.where('projectId').equals(project.id).delete();
- await db.decorators.where('projectId').equals(project.id).delete();
- await db.conditions.where('projectId').equals(project.id).delete();
- await db.localizedStrings.where('projectId').equals(project.id).delete();
-
- await db.projects.put(syncedProject);
-
- if (syncedDialogues.length > 0) await db.dialogues.bulkPut(syncedDialogues);
- if (sanitizedParticipants.length > 0) await db.participants.bulkPut(sanitizedParticipants);
- if (sanitizedCategories.length > 0) await db.categories.bulkPut(sanitizedCategories);
- if (sanitizedDecorators.length > 0) await db.decorators.bulkPut(sanitizedDecorators);
- if (sanitizedConditions.length > 0) await db.conditions.bulkPut(sanitizedConditions);
- if (sanitizedLocalizedStrings.length > 0)
- await db.localizedStrings.bulkPut(sanitizedLocalizedStrings);
- if (sanitizedNodes.length > 0) await db.nodes.bulkPut(sanitizedNodes);
- if (sanitizedEdges.length > 0) await db.edges.bulkPut(sanitizedEdges);
- }
- );
+ const result = await commitPreparedProject(prepared);
+ return result.projectId;
}
-export async function applyProjectSnapshotAsNew(snapshot) {
- const {
- project: rawProject,
- dialogues = [],
- participants = [],
- categories = [],
- decorators = [],
- conditions = [],
- localizedStrings = [],
- nodes = [],
- edges = [],
- } = snapshot || {};
- const project = stripLocalOnlyProjectFields(rawProject);
- const sanitizedDialogues = dialogues.map((dialogue) => stripLocalOnlyDialogueFields(dialogue));
-
- if (!project?.id) {
- throw new Error('Invalid snapshot');
- }
-
- const now = new Date().toISOString();
- const newProjectId = uuidv4();
-
- const newProject = {
- ...project,
- id: newProjectId,
- createdAt: now,
- modifiedAt: now,
- };
-
- const dialogueIdMap = new Map();
- const newDialogues = sanitizedDialogues.map((dialogue) => {
- const newId = uuidv4();
- dialogueIdMap.set(dialogue.id, newId);
- return {
- ...dialogue,
- id: newId,
- projectId: newProjectId,
- createdAt: now,
- modifiedAt: now,
- };
- });
-
- const categoryIdMap = new Map();
- const newCategories = categories.map((category) => {
- const newId = uuidv4();
- categoryIdMap.set(category.id, newId);
- return {
- ...category,
- id: newId,
- projectId: newProjectId,
- createdAt: now,
- modifiedAt: now,
- parentCategoryId: category.parentCategoryId || null,
- };
- });
- const normalizedCategories = newCategories.map((category) => ({
- ...category,
- parentCategoryId: category.parentCategoryId
- ? categoryIdMap.get(category.parentCategoryId) || null
- : null,
- }));
-
- const newParticipants = participants.map((participant) => ({
- ...participant,
- id: uuidv4(),
- projectId: newProjectId,
- createdAt: now,
- modifiedAt: now,
- }));
-
- const newDecorators = decorators.map((decorator) => ({
- ...decorator,
- id: uuidv4(),
- projectId: newProjectId,
- createdAt: now,
- modifiedAt: now,
- }));
- const newConditions = conditions.map((condition) => ({
- ...condition,
- id: uuidv4(),
- projectId: newProjectId,
- createdAt: now,
- modifiedAt: now,
- }));
-
- const newNodes = nodes.map((node) => ({
- ...node,
- dialogueId: dialogueIdMap.get(node.dialogueId) || node.dialogueId,
- }));
-
- const newEdges = edges.map((edge) => ({
- ...edge,
- dialogueId: dialogueIdMap.get(edge.dialogueId) || edge.dialogueId,
- }));
-
- const newLocalizedStrings = localizedStrings.map((entry) => {
- const mappedDialogueId = dialogueIdMap.get(entry.dialogueId) || entry.dialogueId || '';
- const key = buildLocalizedStringKey({
- dialogueId: mappedDialogueId,
- nodeId: entry.nodeId,
- rowId: entry.rowId,
- field: entry.field,
- });
- return {
- ...entry,
- projectId: newProjectId,
- dialogueId: mappedDialogueId,
- key: key || entry.key,
- createdAt: now,
- modifiedAt: now,
- };
- });
-
- await db.transaction(
- 'rw',
- [
- db.projects,
- db.dialogues,
- db.participants,
- db.categories,
- db.decorators,
- db.conditions,
- db.localizedStrings,
- db.nodes,
- db.edges,
- ],
- async () => {
- await db.projects.add(newProject);
- if (newDialogues.length > 0) await db.dialogues.bulkAdd(newDialogues);
- if (newParticipants.length > 0) await db.participants.bulkAdd(newParticipants);
- if (normalizedCategories.length > 0) await db.categories.bulkAdd(normalizedCategories);
- if (newDecorators.length > 0) await db.decorators.bulkAdd(newDecorators);
- if (newConditions.length > 0) await db.conditions.bulkAdd(newConditions);
- if (newLocalizedStrings.length > 0) await db.localizedStrings.bulkAdd(newLocalizedStrings);
- if (newNodes.length > 0) await db.nodes.bulkAdd(newNodes);
- if (newEdges.length > 0) await db.edges.bulkAdd(newEdges);
- }
- );
-
- return newProjectId;
+export async function applyProjectSnapshotAsNew(snapshot, options = {}) {
+ const context = options.context || await getRepositoryContext();
+ const { snapshot: clone } = remapProjectIdentities(await canonicalizeProject(snapshot), { projectId: options.projectId });
+ if (options.name) clone.project.name = options.name;
+ const prepared = await prepareProjectCommit(clone, { context, expectedSequence: 0, operation: 'create' });
+ return (await commitPreparedProject(prepared)).projectId;
}
-
-
-
diff --git a/src/lib/sync/steamCloudClient.js b/src/lib/sync/steamCloudClient.js
index d4b80d8..e538f0f 100644
--- a/src/lib/sync/steamCloudClient.js
+++ b/src/lib/sync/steamCloudClient.js
@@ -1,4 +1,4 @@
-import { getActiveProfileId, setActiveProfileId } from '@/lib/profile/activeProfile';
+import { getRepositoryContext } from '@/lib/db';
function getElectronApi() {
if (typeof window === 'undefined') return null;
@@ -29,46 +29,13 @@ function assertSteamSyncApi() {
return electronApi;
}
-function sanitizeProfileId(rawValue) {
- const value = String(rawValue || '').trim();
- if (!value) return 'local';
- return value.replace(/[^a-zA-Z0-9_-]/g, '-');
-}
-
-function buildSteamProfileId(steamId) {
- const normalizedSteamId = String(steamId || '').trim();
- if (!normalizedSteamId) return '';
- return sanitizeProfileId(`steam-${normalizedSteamId}`);
-}
-
async function resolveSteamSyncProfileId(electronApi) {
- const activeProfileId = sanitizeProfileId(getActiveProfileId());
- if (activeProfileId.startsWith('steam-') && activeProfileId !== 'steam-') {
- return activeProfileId;
- }
-
- try {
- if (typeof electronApi?.getSteamStatus === 'function') {
- const steamStatus = await electronApi.getSteamStatus();
- const isAvailable = Boolean(steamStatus?.available);
- const steamProfileId = buildSteamProfileId(steamStatus?.steamId);
- if (isAvailable && steamProfileId) {
- if (activeProfileId !== steamProfileId) {
- setActiveProfileId(steamProfileId);
- console.info(
- `[steam-sync] Corrected active profile from "${activeProfileId}" to "${steamProfileId}"`
- );
- }
- return steamProfileId;
- }
- }
- } catch (error) {
- console.warn('[steam-sync] Failed to resolve Steam profile id:', error);
- }
-
- return activeProfileId;
+ const context = await getRepositoryContext();
+ const status = await electronApi.getSteamStatus();
+ context.assertCurrent();
+ if (!status?.available || context.profileId !== `steam-${status.steamId}`) throw new Error('Steam profile is not active');
+ return context.profileId;
}
-
export async function findSteamCloudFile(fileName) {
const electronApi = assertSteamSyncApi();
const profileId = await resolveSteamSyncProfileId(electronApi);
@@ -100,8 +67,8 @@ export async function createSteamCloudFile(payload) {
const electronApi = assertSteamSyncApi();
const profileId = await resolveSteamSyncProfileId(electronApi);
return await electronApi.steamSyncCreateFile({
- profileId,
...payload,
+ profileId,
});
}
@@ -109,8 +76,8 @@ export async function updateSteamCloudFile(payload) {
const electronApi = assertSteamSyncApi();
const profileId = await resolveSteamSyncProfileId(electronApi);
return await electronApi.steamSyncUpdateFile({
- profileId,
...payload,
+ profileId,
});
}
diff --git a/src/lib/sync/syncStorage.js b/src/lib/sync/syncStorage.js
index 675e0cd..9f1071f 100644
--- a/src/lib/sync/syncStorage.js
+++ b/src/lib/sync/syncStorage.js
@@ -1,46 +1,39 @@
-import { db } from '@/lib/db';
+import { getRepositoryContext } from '@/lib/db';
+import { readAccount, writeAccount, removeAccount } from '@/lib/sync/credentialStore';
import { getActiveProfileId } from '@/lib/profile/activeProfile';
import { SYNC_TOMBSTONE_TTL_MS } from '@/lib/sync/core/constants';
-export async function getSyncAccount(provider) {
- if (!provider) return null;
- return await db.syncAccounts.get(provider);
+export async function getSyncAccount(provider, context = null) {
+ return provider ? readAccount(provider, context) : null;
}
-
-export async function upsertSyncAccount(provider, data) {
- if (!provider) return null;
- const payload = {
- provider,
- ...data,
- };
- await db.syncAccounts.put(payload);
- return payload;
+export async function upsertSyncAccount(provider, data, context = null) {
+ return provider ? writeAccount(provider, data, context) : null;
}
-
-export async function clearSyncAccount(provider) {
- if (!provider) return;
- await db.syncAccounts.delete(provider);
+export async function clearSyncAccount(provider, context = null) {
+ if (provider) await removeAccount(provider, context);
}
-
export async function getSyncProject(projectId, provider) {
+ const context = await getRepositoryContext(); context.assertCurrent();
if (!projectId || !provider) return null;
- return await db.syncProjects.get([projectId, provider]);
+ return await context.db.syncProjects.get([projectId, provider]);
}
export async function upsertSyncProject(projectId, provider, data) {
+ const context = await getRepositoryContext(); context.assertCurrent();
if (!projectId || !provider) return null;
const payload = {
projectId,
provider,
...data,
};
- await db.syncProjects.put(payload);
+ await context.db.syncProjects.put(payload);
return payload;
}
export async function clearSyncProject(projectId, provider) {
+ const context = await getRepositoryContext(); context.assertCurrent();
if (!projectId || !provider) return;
- await db.syncProjects.delete([projectId, provider]);
+ await context.db.syncProjects.delete([projectId, provider]);
}
function sanitizeProfileId(value) {
@@ -69,18 +62,16 @@ function normalizeIsoDate(value, fallbackIso = '') {
return fallbackIso;
}
-function isExpiredTombstone(entry, nowMs = Date.now()) {
- const expiresAtMs = entry?.expiresAt ? Date.parse(entry.expiresAt) : 0;
- return Boolean(expiresAtMs) && expiresAtMs <= nowMs;
-}
export async function getSyncCatalogState(provider, profileId = '') {
+ const context = await getRepositoryContext(); context.assertCurrent();
if (!provider) return null;
const normalizedProfileId = resolveProfileId(profileId);
- return await db.syncCatalogState.get([provider, normalizedProfileId]);
+ return await context.db.syncCatalogState.get([provider, normalizedProfileId]);
}
export async function upsertSyncCatalogState(provider, profileId = '', data = {}) {
+ const context = await getRepositoryContext(); context.assertCurrent();
if (!provider) return null;
const normalizedProfileId = resolveProfileId(profileId);
const payload = {
@@ -88,27 +79,30 @@ export async function upsertSyncCatalogState(provider, profileId = '', data = {}
profileId: normalizedProfileId,
...data,
};
- await db.syncCatalogState.put(payload);
+ await context.db.syncCatalogState.put(payload);
return payload;
}
export async function listSyncCatalogStates(provider = '') {
+ const context = await getRepositoryContext(); context.assertCurrent();
const providerId = String(provider || '').trim();
if (!providerId) {
- return await db.syncCatalogState.toArray();
+ return await context.db.syncCatalogState.toArray();
}
- return await db.syncCatalogState.where('provider').equals(providerId).toArray();
+ return await context.db.syncCatalogState.where('provider').equals(providerId).toArray();
}
export async function getSyncTombstone({ provider, entityType, entityId } = {}) {
+ const context = await getRepositoryContext(); context.assertCurrent();
const providerId = String(provider || '').trim();
const normalizedEntityType = normalizeEntityType(entityType);
const normalizedEntityId = String(entityId || '').trim();
if (!providerId || !normalizedEntityType || !normalizedEntityId) return null;
- return await db.syncTombstones.get([providerId, normalizedEntityType, normalizedEntityId]);
+ return await context.db.syncTombstones.get([providerId, normalizedEntityType, normalizedEntityId]);
}
export async function upsertSyncTombstone(input = {}) {
+ const context = await getRepositoryContext(); context.assertCurrent();
const providerId = String(input?.provider || '').trim();
const entityType = normalizeEntityType(input?.entityType);
const entityId = String(input?.entityId || '').trim();
@@ -135,7 +129,8 @@ export async function upsertSyncTombstone(input = {}) {
updatedAt: nowIso,
};
- await db.syncTombstones.put(payload);
+ context.assertCurrent();
+ await context.db.syncTombstones.put(payload);
return payload;
}
@@ -145,6 +140,7 @@ export async function acknowledgeSyncTombstone({
entityId,
acknowledgedAt = '',
} = {}) {
+ const context = await getRepositoryContext(); context.assertCurrent();
const existing = await getSyncTombstone({ provider, entityType, entityId });
if (!existing) return null;
const ackAt =
@@ -155,60 +151,45 @@ export async function acknowledgeSyncTombstone({
acknowledgedAt: ackAt,
updatedAt: ackAt,
};
- await db.syncTombstones.put(payload);
+ context.assertCurrent();
+ await context.db.syncTombstones.put(payload);
return payload;
}
export async function listSyncTombstones(options = {}) {
+ const context = await getRepositoryContext(); context.assertCurrent();
const providerId = String(options?.provider || '').trim();
const entityType = normalizeEntityType(options?.entityType);
const entityId = String(options?.entityId || '').trim();
const projectId = String(options?.projectId || '').trim();
const pendingOnly = Boolean(options?.pendingOnly);
- const includeExpired = options?.includeExpired !== false;
let items = [];
if (providerId) {
- items = await db.syncTombstones.where('provider').equals(providerId).toArray();
+ items = await context.db.syncTombstones.where('provider').equals(providerId).toArray();
} else if (entityType) {
- items = await db.syncTombstones.where('entityType').equals(entityType).toArray();
+ items = await context.db.syncTombstones.where('entityType').equals(entityType).toArray();
} else if (entityId) {
- items = await db.syncTombstones.where('entityId').equals(entityId).toArray();
+ items = await context.db.syncTombstones.where('entityId').equals(entityId).toArray();
} else {
- items = await db.syncTombstones.toArray();
+ items = await context.db.syncTombstones.toArray();
}
- const nowMs = Date.now();
return items.filter((item) => {
if (entityType && item.entityType !== entityType) return false;
if (entityId && item.entityId !== entityId) return false;
if (projectId && String(item.projectId || '').trim() !== projectId) return false;
if (pendingOnly && !item.pending) return false;
- if (!includeExpired && isExpiredTombstone(item, nowMs)) return false;
return true;
});
}
-export async function clearSyncTombstone({ provider, entityType, entityId } = {}) {
- const providerId = String(provider || '').trim();
- const normalizedEntityType = normalizeEntityType(entityType);
- const normalizedEntityId = String(entityId || '').trim();
- if (!providerId || !normalizedEntityType || !normalizedEntityId) return;
- await db.syncTombstones.delete([providerId, normalizedEntityType, normalizedEntityId]);
+export async function clearSyncTombstone() {
+ throw new Error('Deletion evidence is retained indefinitely.');
}
-export async function clearSyncTombstonesByEntity({ entityType, entityId } = {}) {
- const normalizedEntityType = normalizeEntityType(entityType);
- const normalizedEntityId = String(entityId || '').trim();
- if (!normalizedEntityType || !normalizedEntityId) return;
- const matches = await listSyncTombstones({
- entityType: normalizedEntityType,
- entityId: normalizedEntityId,
- });
- if (!matches.length) return;
- await db.syncTombstones.bulkDelete(
- matches.map((item) => [item.provider, item.entityType, item.entityId])
- );
+export async function clearSyncTombstonesByEntity() {
+ throw new Error('Deletion evidence is retained indefinitely.');
}
export async function hasActiveTombstone({ entityType, entityId, providers = [] } = {}) {
diff --git a/src/routes/__root.jsx b/src/routes/__root.jsx
index b4b62a0..18b258e 100644
--- a/src/routes/__root.jsx
+++ b/src/routes/__root.jsx
@@ -9,16 +9,23 @@ import { CommandPalette } from '@/components/ui/command-palette';
import { SettingsCommandDialog } from '@/components/ui/SettingsCommandDialog';
import { SyncLoginDialog } from '@/components/sync/SyncLoginDialog';
import { SyncPullDialog } from '@/components/sync/SyncPullDialog';
-import { db } from '@/lib/db';
+import { SyncConflictPanel } from '@/components/sync/SyncConflictPanel';
+import { RecoverySummary } from '@/components/projects/RecoverySummary';
+import { db, getRepositoryContext } from '@/lib/db';
import { openContainingFolder } from '@/lib/export/exportFile';
import { toast } from '@/components/ui/toaster';
-import { useSyncStore } from '@/stores/syncStore';
+import { useSyncStore, startSyncRetryWorker } from '@/stores/syncStore';
import { useSteamStore } from '@/stores/steamStore';
import { useProjectStore } from '@/stores/projectStore';
import { useUIStore } from '@/stores/uiStore';
-import { markUserActivity, trackActiveMinute } from '@/lib/achievements/achievementTracker';
+import { markUserActivity, trackActiveMinute, flushActivity, retryPendingAchievements } from '@/lib/achievements/achievementTracker';
import { isGoogleSyncEnabled, isSteamChannel } from '@/lib/runtimeConfig';
-import { initializeActiveProfileFromSteamStatus } from '@/lib/profile/activeProfile';
+import { initializeActiveProfileFromSteamStatus, subscribeProfileChanges } from '@/lib/profile/activeProfile';
+import { useDialogueStore } from '@/stores/dialogueStore';
+import { useCategoryStore } from '@/stores/categoryStore';
+import { useParticipantStore } from '@/stores/participantStore';
+import { useDecoratorStore } from '@/stores/decoratorStore';
+import { useConditionStore } from '@/stores/conditionStore';
import { getSyncProviderConfig } from '@/lib/sync/providers/providerRegistry';
import { useCommandPaletteStore } from '@/stores/commandPaletteStore';
import { useSettingsCommandStore } from '@/stores/settingsCommandStore';
@@ -60,6 +67,7 @@ function RootComponent() {
const { i18n: runtimeI18n } = useTranslation();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(true);
+ const [initializationError, setInitializationError] = useState(null);
const [showContent, setShowContent] = useState(false);
const [promptedThisSession, setPromptedThisSession] = useState(false);
const [, setDeviceOverrideTick] = useState(0);
@@ -183,31 +191,43 @@ function RootComponent() {
}, []);
useEffect(() => {
- const initializeApp = async () => {
+ let disposed = false;
+ let request = 0;
+ let resolvedSteamStatus;
+ const initializeApp = async (resolveSteam = false) => {
+ const ticket = ++request;
+ setIsLoading(true);
+ setShowContent(false);
+ setInitializationError(null);
try {
- const steamStatus = await loadSteamStatus();
- initializeActiveProfileFromSteamStatus(steamStatus);
- // Rehydrate profile-scoped persisted stores after profile is resolved.
- if (typeof useSyncStore.persist?.rehydrate === 'function') {
- await useSyncStore.persist.rehydrate();
+ if (resolveSteam) {
+ resolvedSteamStatus = await loadSteamStatus();
+ if (disposed || ticket !== request) return;
+ initializeActiveProfileFromSteamStatus(resolvedSteamStatus);
+ if (ticket !== request) return;
}
- // Wait for profile-scoped database to be ready
- await db.open();
- await loadAccount({ steamStatus });
-
- // Ensure minimum loading time for smooth UX (1.5 seconds)
- await new Promise((resolve) => setTimeout(resolve, 1500));
-
- // Mark loading as complete
+ for (const store of [useProjectStore, useDialogueStore, useCategoryStore, useParticipantStore, useDecoratorStore, useConditionStore]) store.setState(store.getInitialState(), true);
+ const context = await getRepositoryContext();
+ // Rehydration merges from defaults so absent settings cannot leak
+ // from the previously active profile. It never writes old values first.
+ useSyncStore.persist.setOptions({ merge: (persisted, current) => ({ ...current, ...useSyncStore.getInitialState(), ...persisted }) });
+ await Promise.all([useUIStore.persist.rehydrate(), useSyncStore.persist.rehydrate()]);
+ context.assertCurrent();
+ if (disposed || ticket !== request) return;
+ await loadAccount({ steamStatus: resolvedSteamStatus });
+ context.assertCurrent();
+ if (disposed || ticket !== request) return;
setIsLoading(false);
} catch (error) {
+ if (disposed || ticket !== request || error.code === 'STALE_PROFILE') return;
console.error('Failed to initialize app:', error);
- // Still proceed even if there's an error
+ setInitializationError(error);
setIsLoading(false);
}
};
-
- initializeApp();
+ const unsubscribe = subscribeProfileChanges(() => { hasAutoSyncedRef.current = false; void initializeApp(); });
+ void initializeApp(true);
+ return () => { disposed = true; request += 1; unsubscribe(); };
}, [loadAccount, loadSteamStatus]);
useEffect(() => {
@@ -224,6 +244,14 @@ function RootComponent() {
markUserActivity();
};
+ const flush = () => flushActivity({ force: true });
+ const visibility = () => { if (document.visibilityState === 'hidden') flush(); };
+ window.addEventListener('pagehide', flush);
+ document.addEventListener('visibilitychange', visibility);
+ const unsubscribeSteam = useSteamStore.subscribe((state, previous) => {
+ if (state.status?.available && (!previous.status?.available || state.status.steamId !== previous.status.steamId)) void retryPendingAchievements();
+ });
+ void retryPendingAchievements();
handleActivity();
activityEvents.forEach((eventName) =>
window.addEventListener(eventName, handleActivity, { passive: true })
@@ -236,6 +264,10 @@ function RootComponent() {
}, 60_000);
return () => {
+ flush();
+ unsubscribeSteam();
+ window.removeEventListener('pagehide', flush);
+ document.removeEventListener('visibilitychange', visibility);
window.clearInterval(timer);
activityEvents.forEach((eventName) =>
window.removeEventListener(eventName, handleActivity)
@@ -298,6 +330,12 @@ function RootComponent() {
steamStatus?.available,
]);
+ useEffect(() => {
+ if (!hasHydrated) return;
+ if (isLoading || !showContent) return;
+ return startSyncRetryWorker();
+ }, [hasHydrated, isLoading, showContent]);
+
useEffect(() => {
if (!hasHydrated) return;
if (status !== 'connected') return;
@@ -720,8 +758,10 @@ function RootComponent() {
isLoading={isLoading}
onLoadingComplete={() => setShowContent(true)}
/>
- {(showContent || !isLoading) && (
+ {initializationError &&
{runtimeI18n.t('errors.storageInitialization', { defaultValue: 'Local storage could not be opened safely. Your original database has been preserved.' })}
{initializationError.message}
}
+ {!initializationError && !isLoading && (showContent || !isLoading) && (
+
@@ -738,6 +778,7 @@ function RootComponent() {
onHideLoginPromptChange={setHideLoginPrompt}
/>
+
>
)}
diff --git a/src/routes/index.jsx b/src/routes/index.jsx
index 1a426e9..82d4b41 100644
--- a/src/routes/index.jsx
+++ b/src/routes/index.jsx
@@ -1,3 +1,4 @@
+import { ArchiveImportDialog } from '@/components/projects/ArchiveImportDialog';
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -506,6 +507,7 @@ function ProjectsDashboard() {
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isCreatingExampleProject, setIsCreatingExampleProject] = useState(false);
const [isImporting, setIsImporting] = useState(false);
+ const [importFile, setImportFile] = useState(null);
const [isImportDragOver, setIsImportDragOver] = useState(false);
const [diskUsageBytes, setDiskUsageBytes] = useState(0);
const { runTour, finishTour, resetTour } = useOnboarding("dashboard");
@@ -597,45 +599,28 @@ function ProjectsDashboard() {
}
};
- const handleImportFileChange = async (event) => {
+ const handleImportFileChange = (event) => {
const file = event.target.files?.[0];
event.target.value = "";
- if (!file) return;
+ if (file) setImportFile(file);
+ };
+
+ const commitImport = async (options) => {
setIsImporting(true);
try {
- const newId = await importProject(file);
- if (newId) {
- await loadProjects();
- await loadDialogues();
- navigate({ to: "/projects/$projectId", params: { projectId: newId } });
- }
- } finally {
- setIsImporting(false);
- }
+ const newId = await importProject(importFile, options);
+ await loadDialogues();
+ navigate({ to: "/projects/$projectId", params: { projectId: newId } });
+ } finally { setIsImporting(false); }
};
- const handleImport = () => {
- importFileInputRef.current?.click();
- };
+ const handleImport = () => { importFileInputRef.current?.click(); };
- const handleImportDrop = async (event) => {
+ const handleImportDrop = (event) => {
event.preventDefault();
setIsImportDragOver(false);
- const file = Array.from(event.dataTransfer.files).find((f) =>
- f.name.endsWith(".mnteadlgproj")
- );
- if (!file || isImporting) return;
- setIsImporting(true);
- try {
- const newId = await importProject(file);
- if (newId) {
- await loadProjects();
- await loadDialogues();
- navigate({ to: "/projects/$projectId", params: { projectId: newId } });
- }
- } finally {
- setIsImporting(false);
- }
+ const file = Array.from(event.dataTransfer.files).find((entry) => entry.name.endsWith(".mnteadlgproj"));
+ if (file && !isImporting) setImportFile(file);
};
const filteredProjects = projects.filter((project) =>
@@ -649,7 +634,7 @@ function ProjectsDashboard() {
}, [dialogues]);
const totalDialogues = dialogues.length;
- const diskUsage = formatFileSize(diskUsageBytes);
+ const diskUsage = diskUsageBytes == null ? t('metrics.unavailable') : formatFileSize(diskUsageBytes);
return (
@@ -659,6 +644,8 @@ function ProjectsDashboard() {
tourType="dashboard"
/>
+ {importFile &&
setImportFile(null)} onImport={commitImport} />}
+
normalizeProjectLocalizationConfig(project?.localization || {}),
[project?.localization]
);
- const localizationEnabled = Boolean(projectLocalization.enabled);
+ const localizationEnabled = true;
const contentLocale = useMemo(() => {
const savedLocale = normalizeLocaleTag(contentLocaleByProject?.[projectId], '');
if (
@@ -376,8 +379,12 @@ function DialogueEditorPage() {
[loadDialogueGraphForPreviewFromStore, contentLocale]
);
- const [nodes, setNodes, onNodesChangeBase] = useNodesState(getInitialNodes(t));
- const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
+ const {
+ nodes, edges, setNodes, setEdges, onNodesChange: onNodesChangeBase, onEdgesChange,
+ hasUnsavedChanges, markUnsaved, saveToHistory, resetDraft,
+ updateNodeData: updateDraftNodeData, captureSave, acknowledgeSave,
+ undo, redo,
+ } = useEditorDraft(getInitialNodes(t), initialEdges);
// Prevent deletion of Start Node
const onNodesChange = useCallback((changes) => {
@@ -387,8 +394,9 @@ function DialogueEditorPage() {
}
return true;
});
+ try { assertGraphNodesRemovable(nodes, new Set(filteredChanges.filter((change) => change.type === 'remove').map((change) => change.id))); } catch (error) { toast(errorToast(error, 'delete')); return; }
onNodesChangeBase(filteredChanges);
- }, [onNodesChangeBase]);
+ }, [nodes, onNodesChangeBase]);
const [selectedNodeId, setSelectedNodeId] = useState(null);
const selectedNode = useMemo(
() => nodes.find((node) => node.id === selectedNodeId) || null,
@@ -396,15 +404,13 @@ function DialogueEditorPage() {
);
const [selectedEdge, setSelectedEdge] = useState(null);
const [, setIsSaving] = useState(false);
+ const saveInFlightRef = useRef(false);
+ const saveQueueRef = useRef(null);
+ if (!saveQueueRef.current) saveQueueRef.current = createEditorSaveQueue();
const [saveStatus, setSaveStatus] = useState('saved');
const [lastSaved, setLastSaved] = useState(null);
const [viewport, setViewport] = useState({ x: 0, y: 0, zoom: 1 });
const [reactFlowInstance, setReactFlowInstance] = useState(null);
- const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
- const markUnsaved = useCallback(() => {
- setHasUnsavedChanges(true);
- setSaveStatus((prev) => (prev === 'saving' ? prev : 'unsaved'));
- }, []);
// Onboarding tour
const { runTour, finishTour, resetTour } = useOnboarding('dialogue-editor');
@@ -439,7 +445,8 @@ function DialogueEditorPage() {
};
updateDeviceType();
window.addEventListener('resize', updateDeviceType);
- return () => window.removeEventListener('resize', updateDeviceType);
+ window.addEventListener('device-override', updateDeviceType);
+ return () => { window.removeEventListener('resize', updateDeviceType); window.removeEventListener('device-override', updateDeviceType); };
}, []);
useLayoutEffect(() => {
@@ -507,12 +514,6 @@ function DialogueEditorPage() {
};
}, [deviceType, isMobilePanelOpen]);
- // History for undo/redo
- const [history, setHistory] = useState([
- { nodes: getInitialNodes(t), edges: initialEdges },
- ]);
- const [historyIndex, setHistoryIndex] = useState(0);
-
// Load data
useEffect(() => {
loadProjects();
@@ -570,10 +571,7 @@ function DialogueEditorPage() {
const { nodes: loadedNodes, edges: loadedEdges, viewport: loadedViewport } = result;
if (loadedNodes.length > 0 || loadedEdges.length > 0) {
- setNodes(loadedNodes);
- setEdges(loadedEdges);
- setHistory([{ nodes: loadedNodes, edges: loadedEdges }]);
- setHistoryIndex(0);
+ resetDraft(loadedNodes, loadedEdges);
}
// Restore viewport if saved and ReactFlow instance is available
@@ -592,7 +590,7 @@ function DialogueEditorPage() {
}
};
loadGraph();
- }, [dialogueId, loadDialogueGraph, setNodes, setEdges, reactFlowInstance, contentLocale]);
+ }, [dialogueId, loadDialogueGraph, resetDraft, reactFlowInstance, contentLocale]);
const isMobileGraphLoading =
deviceType !== 'desktop' &&
@@ -758,25 +756,6 @@ function DialogueEditorPage() {
]);
- // Save to history for undo/redo
- const saveToHistory = useCallback(
- (newNodes, newEdges) => {
- setHistory((prev) => {
- const newHistory = prev.slice(0, historyIndex + 1);
- newHistory.push({ nodes: newNodes, edges: newEdges });
- // Keep only last 50 states to avoid memory issues
- if (newHistory.length > 50) {
- newHistory.shift();
- setHistoryIndex(newHistory.length - 1);
- return newHistory;
- }
- setHistoryIndex(newHistory.length - 1);
- return newHistory;
- });
- },
- [historyIndex]
- );
-
// Save to history when selected node changes (user clicked away after editing)
const prevSelectedNodeRef = useRef(selectedNodeId);
useEffect(() => {
@@ -794,7 +773,7 @@ function DialogueEditorPage() {
}, [selectedNodeId, nodes, edges, saveToHistory]);
// Warn before leaving with unsaved changes
- const shouldWarnOnLeave = hasUnsavedChanges && saveStatus !== 'saved';
+ const shouldWarnOnLeave = hasUnsavedChanges;
useEffect(() => {
const handleBeforeUnload = (e) => {
@@ -879,22 +858,13 @@ function DialogueEditorPage() {
case 'select':
if (field.options === 'participants') {
const groups = new Map();
- const getCategoryPath = (categoryName) => {
- const category = projectCategories.find((c) => c.name === categoryName);
- if (!category) return categoryName;
- const path = [];
- let current = category;
- while (current) {
- path.unshift(current.name);
- current = projectCategories.find(
- (c) => c.id === current.parentCategoryId
- );
- }
- return path.join(' > ');
+ const getCategoryPath = (participant) => {
+ try { return categoryAncestors(participant.categoryId, projectCategories).reverse().map((item) => item.name).join(' > ') || participant.category; }
+ catch { return participant.category || ''; }
};
participants.forEach((participant) => {
const categoryName = participant.category || t('categories.title');
- const categoryPath = getCategoryPath(categoryName);
+ const categoryPath = getCategoryPath(participant);
const root = categoryPath.split(' > ')[0] || categoryName;
if (!groups.has(root)) {
groups.set(root, []);
@@ -917,9 +887,9 @@ function DialogueEditorPage() {
- updateNodeData(selectedNode.id, { [field.id]: e.target.value })
+ updateNodeData(selectedNode.id, { participantId: e.target.value, [field.id]: participants.find((participant) => participant.id === e.target.value)?.name || '' })
}
>