From c5e2a55e84637e58a08e73d252757b6c31369313 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:46:23 +0200 Subject: [PATCH 1/5] fix: add preserve-first desktop corruption recovery --- README.md | 8 +- index.tsx | 127 ++++++++++++++---- services/fs/projectFsStore.ts | 33 ++++- services/storageBackend.ts | 8 ++ services/storageService.ts | 7 + tests/unit/services/fs/fsStores.test.ts | 58 ++++++-- tests/unit/services/fs/projectFsStore.test.ts | 4 +- 7 files changed, 204 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index bc193c884..3d8e44f88 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2925 keys - 7266+ tests / 591 files + 7268+ tests / 591 files Codecov Coverage License MIT CI Status @@ -511,7 +511,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | | **i18n** | Custom React Context (`I18nContext.tsx`) | 2925 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence | -| **Testing** | Vitest 4.x (7266+ tests / 591 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7268+ tests / 591 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | | **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy | | **Visualization** | Force-directed graph | Interactive character relationship network | | **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` | @@ -549,7 +549,7 @@ WorldScript-Studio/ │ ├── sw.js # PWA Service Worker │ └── manifest.json # PWA Web App Manifest v3 ├── tests/ -│ ├── unit/ # Vitest unit tests (7266+ tests, 591 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7268+ tests, 591 files) — count spans tests/, components/, packages/*/tests/, not just this folder │ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths │ │ └── settings/ # WebLlmPanel, AiSections │ └── e2e/ # Playwright specs + helpers.ts @@ -711,7 +711,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt | `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning | **Current test metrics (2026-08-27, source-synchronized; CI remains authoritative for pass/fail):** -- **7266+ unit tests** across **591 test files** — CI is authoritative for pass/fail +- **7268+ unit tests** across **591 test files** — CI is authoritative for pass/fail - Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics) - i18n: **2925 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta) diff --git a/index.tsx b/index.tsx index abdd5d72d..89b5e43e9 100644 --- a/index.tsx +++ b/index.tsx @@ -9,8 +9,11 @@ import { I18nProvider } from './contexts/I18nContext'; import { versionControlActions } from './features/versionControl/versionControlSlice'; import { loadPersistedRootState } from './services/appBootstrap'; import { initializeStorage, resetAllDatabases } from './services/dbInitialization'; +import { ProjectLoadError } from './services/fs/projectFsStore'; import { logger } from './services/logger'; import { IdbStorageLockedError } from './services/storage/storageEncryptionService'; +import { storageService } from './services/storageService'; +import { isTauriRuntime } from './services/tauriRuntime'; /* ── Self-hosted fonts (@fontsource) ── */ import '@fontsource/inter/300.css'; import '@fontsource/inter/400.css'; @@ -103,8 +106,31 @@ window.addEventListener('unhandledrejection', (event) => { renderStartupError(message); }); -/** Recovery UI shown when IndexedDB initialisation fails. Intentionally no i18n dependency. */ -function StorageErrorScreen({ message, onReset }: { message: string; onReset: () => void }) { +/** Recovery UI shown when storage or project initialisation fails. Intentionally no i18n dependency. */ +function StorageErrorScreen({ + message, + onReset, + onRecover, +}: { + message: string; + onReset?: () => void; + onRecover?: () => Promise; +}) { + const [recoveryError, setRecoveryError] = React.useState(null); + const [isRecovering, setIsRecovering] = React.useState(false); + + const handleRecover = async () => { + if (!onRecover) return; + setRecoveryError(null); + setIsRecovering(true); + try { + await onRecover(); + } catch (error) { + setRecoveryError(error instanceof Error ? error.message : String(error)); + setIsRecovering(false); + } + }; + return (

WorldScript Studio

- The local database could not be opened. This can happen after a browser update or when - storage is full. + The local project or database could not be opened. This can happen after a storage or + filesystem failure.

Reload - + {onRecover && ( + + )} + {onReset && ( + + )}

-

- Warning: resetting the database will delete all local projects and settings. -

+ {onRecover && ( +

+ The complete project folder will be moved to the app's quarantined-projects folder; + no project data will be deleted. +

+ )} + {recoveryError && ( +

+ Project preservation failed. The original project was not deleted. {recoveryError} +

+ )} + {onReset && ( +

+ Warning: resetting the database will delete all local projects and settings. +

+ )} ); } @@ -287,14 +346,30 @@ async function bootApp(): Promise { } logger.error('Failed to initialize the application:', error); const msg = error instanceof Error ? error.message : 'Could not load project data.'; + // QNBS-v3: filesystem corruption gets preserve-first recovery; project I/O failures never expose a destructive IDB reset. + const projectLoadError = error instanceof ProjectLoadError ? error : null; + const canQuarantine = projectLoadError?.reason === 'corrupt' && isTauriRuntime(); root.render( { - await resetAllDatabases(); - window.location.reload(); - }} + onRecover={ + canQuarantine + ? async () => { + const result = await storageService.quarantineProject(projectLoadError.projectId); + if (!result) throw new Error('Desktop filesystem recovery is unavailable.'); + window.location.reload(); + } + : undefined + } + onReset={ + projectLoadError + ? undefined + : async () => { + await resetAllDatabases(); + window.location.reload(); + } + } /> , ); diff --git a/services/fs/projectFsStore.ts b/services/fs/projectFsStore.ts index 10fe992cd..7df28a4cf 100644 --- a/services/fs/projectFsStore.ts +++ b/services/fs/projectFsStore.ts @@ -10,7 +10,11 @@ import type { Character, StoryProject, World } from '../../types'; import { getStaticTranslation } from '../i18n/staticTranslate'; import { logger } from '../logger'; import { parseImportedProjectJson } from '../projectImportSchema'; -import { normalizeSaveProjectInputToStoryProject, type SaveProjectInput } from '../storageBackend'; +import { + normalizeSaveProjectInputToStoryProject, + type ProjectQuarantineResult, + type SaveProjectInput, +} from '../storageBackend'; import { FsAssetStore } from './assetFsStore'; import { compressData, @@ -25,6 +29,7 @@ export class ProjectLoadError extends Error { constructor( public readonly reason: 'corrupt' | 'io-error', message: string, + public readonly projectId: string, ) { super(message); this.name = 'ProjectLoadError'; @@ -142,6 +147,7 @@ export class FsProjectStore extends FsAssetStore { throw new ProjectLoadError( 'io-error', `Could not read the project file for "${projectId}" — it may be locked, permission-denied, or otherwise inaccessible.`, + projectId, ); } @@ -157,6 +163,7 @@ export class FsProjectStore extends FsAssetStore { throw new ProjectLoadError( 'corrupt', `The saved project file for "${projectId}" appears to be corrupted and could not be read. The file has not been deleted.`, + projectId, ); } @@ -183,6 +190,30 @@ export class FsProjectStore extends FsAssetStore { } } + /** Move the whole corrupt project directory aside so its manuscript and assets remain recoverable. */ + async quarantineProject(projectId: string): Promise { + const apis = await this.getApis(); + const appDataPath = await this.ensureAppDataPath(); + const safeProjectId = sanitizePathSegment(projectId, 'project'); + const projectPath = await apis.join(appDataPath, 'projects', safeProjectId); + if (!(await apis.exists(projectPath))) { + throw new Error(`Could not quarantine project "${projectId}": project directory not found.`); + } + + const quarantineRoot = await apis.join(appDataPath, 'quarantined-projects'); + await apis.mkdir(quarantineRoot, { recursive: true }); + const timestamp = Date.now(); + for (let attempt = 0; attempt < 100; attempt++) { + const suffix = attempt === 0 ? String(timestamp) : `${timestamp}-${attempt}`; + const quarantinePath = await apis.join(quarantineRoot, `${safeProjectId}-corrupt-${suffix}`); + if (await apis.exists(quarantinePath)) continue; + await retryFs(() => apis.rename(projectPath, quarantinePath)); + return { projectId, path: quarantinePath }; + } + + throw new Error(`Could not quarantine project "${projectId}": recovery target already exists.`); + } + async deleteProject(projectId: string): Promise { const apis = await this.getApis(); const appDataPath = await this.ensureAppDataPath(); diff --git a/services/storageBackend.ts b/services/storageBackend.ts index 83eef5dfe..899f24dcb 100644 --- a/services/storageBackend.ts +++ b/services/storageBackend.ts @@ -13,6 +13,12 @@ export interface BinderAssetPayload { meta: BinderAssetMeta; } +/** Result of moving a corrupt desktop project out of the active project namespace. */ +export interface ProjectQuarantineResult { + projectId: string; + path: string; +} + /** IndexedDB / filesystem key for binder blobs — stable delimiter avoids UUID clashes. */ export function makeBinderAssetStorageKey(projectId: string, assetId: string): string { const safeProject = projectId.replace(/[\s:]/g, '_').slice(0, 200); @@ -66,6 +72,8 @@ export interface StorageBackend { deleteProject(projectId: string): Promise; /** QNBS-v3 (#332): optional — only the multi-project Tauri filesystem backend implements this; IndexedDB's single-project contract has no "which one" ambiguity to resolve. */ getActiveProjectId?(): Promise; + /** Desktop-only preserve-first recovery; IndexedDB has no filesystem project to quarantine. */ + quarantineProject?(projectId: string): Promise; saveImage(id: string, base64Data: string): Promise; getImage(id: string): Promise; diff --git a/services/storageService.ts b/services/storageService.ts index a1e4a7432..a1670b29c 100644 --- a/services/storageService.ts +++ b/services/storageService.ts @@ -2,6 +2,7 @@ import type { ProjectSnapshot, Settings, StoryCodex, StoryProject } from '../typ import type { BinderAssetMeta, BinderAssetPayload, + ProjectQuarantineResult, SaveProjectInput, StorageBackend, } from './storageBackend'; @@ -9,6 +10,7 @@ import type { export type { BinderAssetMeta, BinderAssetPayload, + ProjectQuarantineResult, SaveProjectEnvelope, SaveProjectInput, StorageBackend, @@ -89,6 +91,11 @@ class StorageManager { return (await backend.getActiveProjectId?.()) ?? null; } + async quarantineProject(projectId: string): Promise { + const backend = await this.getBackend(); + return (await backend.quarantineProject?.(projectId)) ?? null; + } + async deleteProject(projectId: string): Promise { const backend = await this.getBackend(); return backend.deleteProject(projectId); diff --git a/tests/unit/services/fs/fsStores.test.ts b/tests/unit/services/fs/fsStores.test.ts index a7bd98e4b..823c0bd44 100644 --- a/tests/unit/services/fs/fsStores.test.ts +++ b/tests/unit/services/fs/fsStores.test.ts @@ -53,7 +53,8 @@ vi.mock('../../../../services/logger', async (importOriginal) => { }); // QNBS-v3: getStaticTranslation hits the network (fetch) — never call real network in tests. vi.mock('../../../../services/i18n/staticTranslate', () => ({ - getStaticTranslation: (key: string) => Promise.resolve(key === 'export.loglineLabel' ? 'Logline' : 'Manuscript'), + getStaticTranslation: (key: string) => + Promise.resolve(key === 'export.loglineLabel' ? 'Logline' : 'Manuscript'), })); import { appStoreRef } from '../../../../app/storeRef'; @@ -111,15 +112,31 @@ function makeFakeFs(): FakeFs { return Promise.resolve(); }, rename: (from: string, to: string) => { - if (!text.has(from) && !bin.has(from)) return Promise.reject(new Error(`ENOENT ${from}`)); + const fromEntries = [...text.keys(), ...bin.keys(), ...dirs].filter( + (path, index, paths) => + paths.indexOf(path) === index && (path === from || path.startsWith(`${from}/`)), + ); + if (fromEntries.length === 0) return Promise.reject(new Error(`ENOENT ${from}`)); + const targetDirectoryExists = + dirs.has(to) || + [...text.keys(), ...bin.keys(), ...dirs].some((path) => path.startsWith(`${to}/`)); + if (targetDirectoryExists) return Promise.reject(new Error(`EEXIST ${to}`)); text.delete(to); bin.delete(to); - const textValue = text.get(from); - const binaryValue = bin.get(from); - if (textValue !== undefined) text.set(to, textValue); - if (binaryValue !== undefined) bin.set(to, binaryValue); - text.delete(from); - bin.delete(from); + for (const path of fromEntries) { + const target = `${to}${path.slice(from.length)}`; + const textValue = text.get(path); + const binaryValue = bin.get(path); + if (textValue !== undefined) { + text.delete(path); + text.set(target, textValue); + } + if (binaryValue !== undefined) { + bin.delete(path); + bin.set(target, binaryValue); + } + if (dirs.delete(path)) dirs.add(target); + } return Promise.resolve(); }, readDir: (p: string) => Promise.resolve(under(p).map((name) => ({ name, isDirectory: false }))), @@ -169,6 +186,31 @@ describe('FsProjectStore — projects', () => { expect(await store.listProjects()).toEqual([]); }); + it('quarantines a complete project directory without deleting or relisting it', async () => { + await store.saveProject(project as never); + const original = fake.text.get('/app/projects/p1/project.json'); + + const result = await store.quarantineProject('p1'); + + expect(result.projectId).toBe('p1'); + expect(result.path).toMatch(/^\/app\/quarantined-projects\/p1-corrupt-/); + expect(fake.text.get(`${result.path}/project.json`)).toBe(original); + expect(fake.text.has('/app/projects/p1/project.json')).toBe(false); + expect(await store.listProjects()).not.toContain('p1'); + await expect(store.loadProject('p1')).resolves.toBeNull(); + }); + + it('leaves the original project intact when quarantine cannot rename it', async () => { + await store.saveProject(project as never); + const original = fake.text.get('/app/projects/p1/project.json'); + fake.apis.rename = () => Promise.reject(new Error('EACCES: permission denied')); + + await expect(store.quarantineProject('p1')).rejects.toThrow('EACCES'); + + expect(fake.text.get('/app/projects/p1/project.json')).toBe(original); + expect(await store.listProjects()).toContain('p1'); + }); + // QNBS-v3: the store test protects the non-blocking scheduling seam without asserting a verdict authority. it('returns the decoded project without waiting for or adopting the shadow verdict', async () => { await store.saveProject(project as never); diff --git a/tests/unit/services/fs/projectFsStore.test.ts b/tests/unit/services/fs/projectFsStore.test.ts index c90e671af..220cd7425 100644 --- a/tests/unit/services/fs/projectFsStore.test.ts +++ b/tests/unit/services/fs/projectFsStore.test.ts @@ -63,7 +63,7 @@ describe('FsProjectStore.loadProject — DA-01 fail-closed behavior', () => { const store = new FsProjectStore(); const promise = store.loadProject('locked-id'); await expect(promise).rejects.toThrow(ProjectLoadError); - await expect(promise).rejects.toMatchObject({ reason: 'io-error' }); + await expect(promise).rejects.toMatchObject({ reason: 'io-error', projectId: 'locked-id' }); }); it('throws ProjectLoadError("corrupt") on a corrupt/truncated compressed payload', async () => { @@ -75,7 +75,7 @@ describe('FsProjectStore.loadProject — DA-01 fail-closed behavior', () => { const store = new FsProjectStore(); const promise = store.loadProject('corrupt-id'); await expect(promise).rejects.toThrow(ProjectLoadError); - await expect(promise).rejects.toMatchObject({ reason: 'corrupt' }); + await expect(promise).rejects.toMatchObject({ reason: 'corrupt', projectId: 'corrupt-id' }); }); it('throws ProjectLoadError("corrupt") on valid JSON that is not project-shaped at all', async () => { From 110cfa70d39ced51b098993a08bcb71bac35e0bc Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:25:02 +0200 Subject: [PATCH 2/5] fix: preserve S1 recovery semantics --- index.tsx | 24 ++++++++++++------------ tests/unit/libraryBackupService.test.ts | 6 +++++- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/index.tsx b/index.tsx index 89b5e43e9..9055cfddc 100644 --- a/index.tsx +++ b/index.tsx @@ -353,23 +353,23 @@ async function bootApp(): Promise { { + {...(canQuarantine + ? { + onRecover: async () => { const result = await storageService.quarantineProject(projectLoadError.projectId); if (!result) throw new Error('Desktop filesystem recovery is unavailable.'); window.location.reload(); - } - : undefined - } - onReset={ - projectLoadError - ? undefined - : async () => { + }, + } + : {})} + {...(!projectLoadError + ? { + onReset: async () => { await resetAllDatabases(); window.location.reload(); - } - } + }, + } + : {})} /> , ); diff --git a/tests/unit/libraryBackupService.test.ts b/tests/unit/libraryBackupService.test.ts index 9cafa479e..cdbfb3c88 100644 --- a/tests/unit/libraryBackupService.test.ts +++ b/tests/unit/libraryBackupService.test.ts @@ -97,7 +97,11 @@ describe('libraryBackupService — partial corruption (DA-01)', () => { vi.mocked(storageService.listProjects).mockResolvedValue(['good', 'corrupt']); vi.mocked(storageService.loadProject).mockImplementation(async (projectId: string) => { if (projectId === 'corrupt') { - throw new ProjectLoadError('corrupt', 'The saved project file for "corrupt" is corrupted.'); + throw new ProjectLoadError( + 'corrupt', + 'The saved project file for "corrupt" is corrupted.', + 'corrupt', + ); } return minimalProject() as unknown as StoryProject; }); From f574c58e7246a3b158641194c970bdc6c11d221a Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:39:15 +0200 Subject: [PATCH 3/5] fix: close S1 quarantine review findings --- README.md | 8 ++++---- services/fs/projectFsStore.ts | 11 +++++++++-- tests/unit/services/fs/fsStores.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3d8e44f88..1bf78b705 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2925 keys - 7268+ tests / 591 files + 7269+ tests / 591 files Codecov Coverage License MIT CI Status @@ -511,7 +511,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | | **i18n** | Custom React Context (`I18nContext.tsx`) | 2925 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence | -| **Testing** | Vitest 4.x (7268+ tests / 591 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7269+ tests / 591 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | | **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy | | **Visualization** | Force-directed graph | Interactive character relationship network | | **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` | @@ -549,7 +549,7 @@ WorldScript-Studio/ │ ├── sw.js # PWA Service Worker │ └── manifest.json # PWA Web App Manifest v3 ├── tests/ -│ ├── unit/ # Vitest unit tests (7268+ tests, 591 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7269+ tests, 591 files) — count spans tests/, components/, packages/*/tests/, not just this folder │ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths │ │ └── settings/ # WebLlmPanel, AiSections │ └── e2e/ # Playwright specs + helpers.ts @@ -711,7 +711,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt | `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning | **Current test metrics (2026-08-27, source-synchronized; CI remains authoritative for pass/fail):** -- **7268+ unit tests** across **591 test files** — CI is authoritative for pass/fail +- **7269+ unit tests** across **591 test files** — CI is authoritative for pass/fail - Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics) - i18n: **2925 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta) diff --git a/services/fs/projectFsStore.ts b/services/fs/projectFsStore.ts index 7df28a4cf..57e065b6f 100644 --- a/services/fs/projectFsStore.ts +++ b/services/fs/projectFsStore.ts @@ -190,6 +190,7 @@ export class FsProjectStore extends FsAssetStore { } } + // QNBS-v3: move the whole folder before reload so corrupt project artifacts remain recoverable. /** Move the whole corrupt project directory aside so its manuscript and assets remain recoverable. */ async quarantineProject(projectId: string): Promise { const apis = await this.getApis(); @@ -207,8 +208,14 @@ export class FsProjectStore extends FsAssetStore { const suffix = attempt === 0 ? String(timestamp) : `${timestamp}-${attempt}`; const quarantinePath = await apis.join(quarantineRoot, `${safeProjectId}-corrupt-${suffix}`); if (await apis.exists(quarantinePath)) continue; - await retryFs(() => apis.rename(projectPath, quarantinePath)); - return { projectId, path: quarantinePath }; + try { + await retryFs(() => apis.rename(projectPath, quarantinePath)); + return { projectId, path: quarantinePath }; + } catch (error) { + // QNBS-v3: a concurrent quarantine may claim the checked target between exists and rename. + if (await apis.exists(quarantinePath)) continue; + throw error; + } } throw new Error(`Could not quarantine project "${projectId}": recovery target already exists.`); diff --git a/tests/unit/services/fs/fsStores.test.ts b/tests/unit/services/fs/fsStores.test.ts index 823c0bd44..4b842300d 100644 --- a/tests/unit/services/fs/fsStores.test.ts +++ b/tests/unit/services/fs/fsStores.test.ts @@ -200,6 +200,28 @@ describe('FsProjectStore — projects', () => { await expect(store.loadProject('p1')).resolves.toBeNull(); }); + it('tries the next quarantine name when a concurrent rename claims the checked target', async () => { + await store.saveProject(project as never); + const originalRename = fake.apis.rename; + let firstTarget: string | undefined; + let racePending = true; + fake.apis.rename = (from: string, to: string) => { + if (racePending) { + racePending = false; + firstTarget = to; + return fake.apis.mkdir(to).then(() => originalRename(from, to)); + } + return originalRename(from, to); + }; + + const result = await store.quarantineProject('p1'); + + expect(firstTarget).toBeDefined(); + expect(result.path).not.toBe(firstTarget); + expect(fake.text.get(`${result.path}/project.json`)).toBeDefined(); + expect(fake.text.has('/app/projects/p1/project.json')).toBe(false); + }); + it('leaves the original project intact when quarantine cannot rename it', async () => { await store.saveProject(project as never); const original = fake.text.get('/app/projects/p1/project.json'); From 5c4bf2926f98200d25e7599e10c76cf4fdcde6e8 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:44:32 +0200 Subject: [PATCH 4/5] fix: close bounded S1 review findings --- README.md | 16 +- components/StorageErrorScreen.tsx | 226 ++++++++++++++++++ index.tsx | 182 +------------- locales/ar/common.json | 13 +- locales/de/common.json | 13 +- locales/el/common.json | 13 +- locales/en/common.json | 13 +- locales/es/common.json | 13 +- locales/eu/common.json | 13 +- locales/fa/common.json | 13 +- locales/fi/common.json | 13 +- locales/fr/common.json | 13 +- locales/he/common.json | 13 +- locales/hu/common.json | 13 +- locales/is/common.json | 13 +- locales/it/common.json | 13 +- locales/ja/common.json | 13 +- locales/ko/common.json | 13 +- locales/pt/common.json | 13 +- locales/ru/common.json | 13 +- locales/sv/common.json | 13 +- locales/zh/common.json | 13 +- public/locales/ar/bundle.json | 11 + public/locales/de/bundle.json | 11 + public/locales/el/bundle.json | 11 + public/locales/en/bundle.json | 11 + public/locales/es/bundle.json | 11 + public/locales/eu/bundle.json | 11 + public/locales/fa/bundle.json | 11 + public/locales/fi/bundle.json | 11 + public/locales/fr/bundle.json | 11 + public/locales/he/bundle.json | 11 + public/locales/hu/bundle.json | 11 + public/locales/is/bundle.json | 11 + public/locales/it/bundle.json | 11 + public/locales/ja/bundle.json | 11 + public/locales/ko/bundle.json | 11 + public/locales/pt/bundle.json | 11 + public/locales/ru/bundle.json | 11 + public/locales/sv/bundle.json | 11 + public/locales/zh/bundle.json | 11 + services/fs/projectFsStore.ts | 82 +++++-- services/startupRecovery.tsx | 63 +++++ services/startupRecoveryPolicy.ts | 15 ++ services/storageBackend.ts | 3 +- services/storageService.ts | 1 + tests/unit/StorageErrorScreen.test.tsx | 103 ++++++++ tests/unit/services/fs/fsStores.test.ts | 22 +- tests/unit/services/fs/projectFsStore.test.ts | 2 + tests/unit/startupRecovery.test.tsx | 128 ++++++++++ tests/unit/startupRecoveryPolicy.test.ts | 38 +++ tests/unit/storageService.test.ts | 5 + 52 files changed, 1116 insertions(+), 226 deletions(-) create mode 100644 components/StorageErrorScreen.tsx create mode 100644 services/startupRecovery.tsx create mode 100644 services/startupRecoveryPolicy.ts create mode 100644 tests/unit/StorageErrorScreen.test.tsx create mode 100644 tests/unit/startupRecovery.test.tsx create mode 100644 tests/unit/startupRecoveryPolicy.test.ts diff --git a/README.md b/README.md index 1bf78b705..65566387f 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ Release v1.28.3 IndexedDB v8 PWA v3.0 - i18n 19 locales — 2925 keys - 7269+ tests / 591 files + i18n 19 locales — 2936 keys + 7282+ tests / 594 files Codecov Coverage License MIT CI Status @@ -401,7 +401,7 @@ Infrastructure-level features that keep the app fast and extensible as projects ### 🌐 Full Multi-Language Support -Shipped UI locales with **2925 i18n keys** across all 19 languages — zero hardcoded user-facing strings: +Shipped UI locales with **2936 i18n keys** across all 19 languages — zero hardcoded user-facing strings: - 🇩🇪 **German** (Deutsch) - 🇬🇧 **English** @@ -510,8 +510,8 @@ The Settings → AI panel shows a live GPU status badge with adapter details and | **PDF Export** | jsPDF | Client-side, configurable PDF document generation | | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | -| **i18n** | Custom React Context (`I18nContext.tsx`) | 2925 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence | -| **Testing** | Vitest 4.x (7269+ tests / 591 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **i18n** | Custom React Context (`I18nContext.tsx`) | 2936 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence | +| **Testing** | Vitest 4.x (7282+ tests / 594 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | | **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy | | **Visualization** | Force-directed graph | Interactive character relationship network | | **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` | @@ -549,7 +549,7 @@ WorldScript-Studio/ │ ├── sw.js # PWA Service Worker │ └── manifest.json # PWA Web App Manifest v3 ├── tests/ -│ ├── unit/ # Vitest unit tests (7269+ tests, 591 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7282+ tests, 594 files) — count spans tests/, components/, packages/*/tests/, not just this folder │ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths │ │ └── settings/ # WebLlmPanel, AiSections │ └── e2e/ # Playwright specs + helpers.ts @@ -711,9 +711,9 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt | `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning | **Current test metrics (2026-08-27, source-synchronized; CI remains authoritative for pass/fail):** -- **7269+ unit tests** across **591 test files** — CI is authoritative for pass/fail +- **7282+ unit tests** across **594 test files** — CI is authoritative for pass/fail - Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics) -- i18n: **2925 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta) +- i18n: **2936 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta) **CI-cloud-first workflow (recommended):** On constrained hardware run **`pnpm run lint && pnpm run i18n:check && pnpm run typecheck`** locally, then push and let CI handle coverage, E2E, Lighthouse, and Stryker. Authoritative numbers come from CI artifacts (Codecov, JUnit). After CI goes green, update the README badges and `AUDIT.md` quality-gate line from the reported metrics. See **[`docs/CI.md`](docs/CI.md) § Cloud CI-first vs local development** for the full post-merge doc-update checklist. diff --git a/components/StorageErrorScreen.tsx b/components/StorageErrorScreen.tsx new file mode 100644 index 000000000..66e469e94 --- /dev/null +++ b/components/StorageErrorScreen.tsx @@ -0,0 +1,226 @@ +import React from 'react'; +import { ProjectQuarantineError } from '../services/fs/projectFsStore'; +import { getStaticTranslation } from '../services/i18n/staticTranslate'; +import { logger } from '../services/logger'; + +export interface StorageErrorCopy { + description: string; + storageUnavailable: string; + projectUnavailable: string; + reload: string; + recover: string; + recovering: string; + reset: string; + quarantineNotice: string; + recoveryFailed: string; + recoveryAlreadyPreserved: string; + resetWarning: string; +} + +export const STARTUP_COPY_FALLBACKS: StorageErrorCopy = { + description: 'The local project or database could not be opened. Reload and try again.', + storageUnavailable: 'Local storage could not be opened. Reload and try again.', + projectUnavailable: 'A local project could not be opened. Reload and try again.', + reload: 'Reload', + recover: 'Quarantine project and reload', + recovering: 'Preserving project…', + reset: 'Reset database and reload', + quarantineNotice: + 'The complete project folder will be moved to quarantine. No project data will be deleted.', + recoveryFailed: + 'Project preservation failed. The original project was not deleted. Reload and try again.', + recoveryAlreadyPreserved: + 'The project appears to have been preserved by another recovery attempt. Reload to continue.', + resetWarning: 'Resetting the database will delete all local projects and settings.', +}; + +async function startupTranslation(key: string, fallback: string): Promise { + const translated = await getStaticTranslation(key); + return translated === key ? fallback : translated; +} + +// QNBS-v3: load safe localized recovery copy before rendering so raw storage errors never reach users. +export async function loadStorageErrorCopy(): Promise { + const [ + description, + storageUnavailable, + projectUnavailable, + reload, + recover, + recovering, + reset, + quarantineNotice, + recoveryFailed, + recoveryAlreadyPreserved, + resetWarning, + ] = await Promise.all([ + startupTranslation('error.startup.description', STARTUP_COPY_FALLBACKS.description), + startupTranslation( + 'error.startup.storageUnavailable', + STARTUP_COPY_FALLBACKS.storageUnavailable, + ), + startupTranslation( + 'error.startup.projectUnavailable', + STARTUP_COPY_FALLBACKS.projectUnavailable, + ), + startupTranslation('error.startup.reload', STARTUP_COPY_FALLBACKS.reload), + startupTranslation('error.startup.recover', STARTUP_COPY_FALLBACKS.recover), + startupTranslation('error.startup.recovering', STARTUP_COPY_FALLBACKS.recovering), + startupTranslation('error.startup.reset', STARTUP_COPY_FALLBACKS.reset), + startupTranslation('error.startup.quarantineNotice', STARTUP_COPY_FALLBACKS.quarantineNotice), + startupTranslation('error.startup.recoveryFailed', STARTUP_COPY_FALLBACKS.recoveryFailed), + startupTranslation( + 'error.startup.recoveryAlreadyPreserved', + STARTUP_COPY_FALLBACKS.recoveryAlreadyPreserved, + ), + startupTranslation('error.startup.resetWarning', STARTUP_COPY_FALLBACKS.resetWarning), + ]); + return { + description, + storageUnavailable, + projectUnavailable, + reload, + recover, + recovering, + reset, + quarantineNotice, + recoveryFailed, + recoveryAlreadyPreserved, + resetWarning, + }; +} + +// QNBS-v3: show stable localized recovery actions while keeping technical filesystem diagnostics in the logger. +export function StorageErrorScreen({ + copy, + onReset, + onRecover, +}: { + copy: StorageErrorCopy; + onReset?: () => void; + onRecover?: () => Promise; +}) { + const [recoveryStatus, setRecoveryStatus] = React.useState<'failed' | 'already-preserved' | null>( + null, + ); + const [isRecovering, setIsRecovering] = React.useState(false); + + const handleRecover = async () => { + if (!onRecover) return; + setRecoveryStatus(null); + setIsRecovering(true); + try { + await onRecover(); + setIsRecovering(false); + } catch (error) { + logger.error('Project quarantine failed', { + error: error instanceof Error ? error.message : String(error), + }); + setRecoveryStatus( + error instanceof ProjectQuarantineError && error.reason === 'already-preserved' + ? 'already-preserved' + : 'failed', + ); + setIsRecovering(false); + } + }; + + return ( +
+

WorldScript Studio

+

{copy.description}

+

+ {onRecover ? copy.projectUnavailable : copy.storageUnavailable} +

+
+ + {onRecover && ( + + )} + {onReset && ( + + )} +
+ {onRecover && ( +

+ {copy.quarantineNotice} +

+ )} + {recoveryStatus && ( +

+ {recoveryStatus === 'already-preserved' + ? copy.recoveryAlreadyPreserved + : copy.recoveryFailed} +

+ )} + {onReset &&

{copy.resetWarning}

} +
+ ); +} diff --git a/index.tsx b/index.tsx index 9055cfddc..b2e0d634b 100644 --- a/index.tsx +++ b/index.tsx @@ -8,12 +8,13 @@ import { IdbUnlockModal } from './components/settings/IdbUnlockModal'; import { I18nProvider } from './contexts/I18nContext'; import { versionControlActions } from './features/versionControl/versionControlSlice'; import { loadPersistedRootState } from './services/appBootstrap'; -import { initializeStorage, resetAllDatabases } from './services/dbInitialization'; -import { ProjectLoadError } from './services/fs/projectFsStore'; +import { initializeStorage } from './services/dbInitialization'; import { logger } from './services/logger'; +import { + renderProjectInitializationFailure, + renderStorageInitializationFailure, +} from './services/startupRecovery'; import { IdbStorageLockedError } from './services/storage/storageEncryptionService'; -import { storageService } from './services/storageService'; -import { isTauriRuntime } from './services/tauriRuntime'; /* ── Self-hosted fonts (@fontsource) ── */ import '@fontsource/inter/300.css'; import '@fontsource/inter/400.css'; @@ -106,154 +107,13 @@ window.addEventListener('unhandledrejection', (event) => { renderStartupError(message); }); -/** Recovery UI shown when storage or project initialisation fails. Intentionally no i18n dependency. */ -function StorageErrorScreen({ - message, - onReset, - onRecover, -}: { - message: string; - onReset?: () => void; - onRecover?: () => Promise; -}) { - const [recoveryError, setRecoveryError] = React.useState(null); - const [isRecovering, setIsRecovering] = React.useState(false); - - const handleRecover = async () => { - if (!onRecover) return; - setRecoveryError(null); - setIsRecovering(true); - try { - await onRecover(); - } catch (error) { - setRecoveryError(error instanceof Error ? error.message : String(error)); - setIsRecovering(false); - } - }; - - return ( -
-

WorldScript Studio

-

- The local project or database could not be opened. This can happen after a storage or - filesystem failure. -

-

- {message} -

-
- - {onRecover && ( - - )} - {onReset && ( - - )} -
- {onRecover && ( -

- The complete project folder will be moved to the app's quarantined-projects folder; - no project data will be deleted. -

- )} - {recoveryError && ( -

- Project preservation failed. The original project was not deleted. {recoveryError} -

- )} - {onReset && ( -

- Warning: resetting the database will delete all local projects and settings. -

- )} -
- ); -} - // QNBS-v3: named (not an anonymous IIFE) so a locked-storage catch below can retry the full boot // sequence after the user unlocks, without a page reload — a reload would lose the in-memory key. async function bootApp(): Promise { const initResult = await initializeStorage(); if (!initResult.success) { logger.error('StorageBackend: initializeStorage failed:', initResult.error); - root.render( - - { - await resetAllDatabases(); - window.location.reload(); - }} - /> - , - ); + await renderStorageInitializationFailure(root); return; } @@ -344,35 +204,7 @@ async function bootApp(): Promise { ); return; } - logger.error('Failed to initialize the application:', error); - const msg = error instanceof Error ? error.message : 'Could not load project data.'; - // QNBS-v3: filesystem corruption gets preserve-first recovery; project I/O failures never expose a destructive IDB reset. - const projectLoadError = error instanceof ProjectLoadError ? error : null; - const canQuarantine = projectLoadError?.reason === 'corrupt' && isTauriRuntime(); - root.render( - - { - const result = await storageService.quarantineProject(projectLoadError.projectId); - if (!result) throw new Error('Desktop filesystem recovery is unavailable.'); - window.location.reload(); - }, - } - : {})} - {...(!projectLoadError - ? { - onReset: async () => { - await resetAllDatabases(); - window.location.reload(); - }, - } - : {})} - /> - , - ); + await renderProjectInitializationFailure(root, error); } } diff --git a/locales/ar/common.json b/locales/ar/common.json index 1e510e033..5846ef5fc 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -695,5 +695,16 @@ "voice.stopDictation": "إيقاف الإملاء", "voice.stopListening": "إيقاف الاستماع", "worlds.emptyState.description": "ابنِ الأماكن والقواعد والتواريخ التي تعيش فيها قصتك. ابدأ بموقع.", - "worlds.emptyState.title": "العالم بانتظارك" + "worlds.emptyState.title": "العالم بانتظارك", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings." } diff --git a/locales/de/common.json b/locales/de/common.json index c30ccfb00..dff383639 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -695,5 +695,16 @@ "voice.stopDictation": "Diktat stoppen", "voice.stopListening": "Zuhören stoppen", "worlds.emptyState.description": "Baue die Orte, Regeln und Geschichten auf, in denen deine Geschichte lebt. Beginne mit einem Ort.", - "worlds.emptyState.title": "Die Welt wartet" + "worlds.emptyState.title": "Die Welt wartet", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings." } diff --git a/locales/el/common.json b/locales/el/common.json index e05d539b2..08c0f7dbb 100644 --- a/locales/el/common.json +++ b/locales/el/common.json @@ -695,5 +695,16 @@ "voice.stopDictation": "Σταματήστε την υπαγόρευση", "voice.stopListening": "Σταμάτα να ακούς", "worlds.emptyState.description": "Δημιουργήστε τα μέρη, τους κανόνες και τις ιστορίες στα οποία ζει η ιστορία σας. Ξεκινήστε με μια τοποθεσία.", - "worlds.emptyState.title": "Ο κόσμος περιμένει" + "worlds.emptyState.title": "Ο κόσμος περιμένει", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings." } diff --git a/locales/en/common.json b/locales/en/common.json index 0e0bf3776..7120c3eaf 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -695,5 +695,16 @@ "plotBoard.heuristic.complicate.rationale": "A fresh complication tests the characters and opens new threads.", "plotBoard.heuristic.reverse.title": "Add a reversal", "plotBoard.heuristic.reverse.description": "Flip an expectation — an ally falters, a win turns sour, or a hidden truth surfaces.", - "plotBoard.heuristic.reverse.rationale": "Reversals refresh tension and deepen the arc." + "plotBoard.heuristic.reverse.rationale": "Reversals refresh tension and deepen the arc.", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings." } diff --git a/locales/es/common.json b/locales/es/common.json index 71511fc46..15f960f8e 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -695,5 +695,16 @@ "voice.stopDictation": "Detener dictado", "voice.stopListening": "Detener escucha", "worlds.emptyState.description": "Construye los lugares, reglas e historias en los que vive tu historia. Comienza con una ubicación.", - "worlds.emptyState.title": "El mundo te espera" + "worlds.emptyState.title": "El mundo te espera", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings." } diff --git a/locales/eu/common.json b/locales/eu/common.json index 11683111b..4c3c1df61 100644 --- a/locales/eu/common.json +++ b/locales/eu/common.json @@ -695,5 +695,16 @@ "voice.stopDictation": "Utzi diktaketari", "voice.stopListening": "Utzi entzuteari", "worlds.emptyState.description": "Eraiki zure istorioa bizi den lekuak, arauak eta historiak. Hasi kokapen batekin.", - "worlds.emptyState.title": "Mundua zain dago" + "worlds.emptyState.title": "Mundua zain dago", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings." } diff --git a/locales/fa/common.json b/locales/fa/common.json index 28dd9bf19..520285497 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -695,5 +695,16 @@ "voice.stopDictation": "دیکته را متوقف کنید", "voice.stopListening": "گوش دادن را متوقف کنید", "worlds.emptyState.description": "مکان‌ها، قوانین و تاریخ‌هایی را بسازید که داستانتان در آن زندگی می‌کند. با یک مکان شروع کنید.", - "worlds.emptyState.title": "دنیا منتظر است" + "worlds.emptyState.title": "دنیا منتظر است", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings." } diff --git a/locales/fi/common.json b/locales/fi/common.json index 2f9e73e25..d0e98b319 100644 --- a/locales/fi/common.json +++ b/locales/fi/common.json @@ -695,5 +695,16 @@ "voice.stopDictation": "Lopeta sanelu", "voice.stopListening": "Lopeta kuunteleminen", "worlds.emptyState.description": "Rakenna paikat, säännöt ja historiat, joissa tarinasi elää. Aloita sijainnista.", - "worlds.emptyState.title": "Maailma odottaa" + "worlds.emptyState.title": "Maailma odottaa", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings." } diff --git a/locales/fr/common.json b/locales/fr/common.json index 5710d2efc..d665e7013 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -695,5 +695,16 @@ "voice.stopDictation": "Arrêter la dictée", "voice.stopListening": "Arrêter l’écoute", "worlds.emptyState.description": "Construisez les lieux, les règles et les histoires dans lesquels votre récit prend vie. Commencez par un lieu.", - "worlds.emptyState.title": "Le monde vous attend" + "worlds.emptyState.title": "Le monde vous attend", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings." } diff --git a/locales/he/common.json b/locales/he/common.json index ad004c4b2..b177d199b 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -695,5 +695,16 @@ "voice.stopDictation": "עצירת הכתבה", "voice.stopListening": "עצירת האזנה", "worlds.emptyState.description": "בנו את המקומות, החוקים וההיסטוריות שבהם הסיפור שלכם חי. התחילו במיקום.", - "worlds.emptyState.title": "העולם ממתין" + "worlds.emptyState.title": "העולם ממתין", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings." } diff --git a/locales/hu/common.json b/locales/hu/common.json index 72c3b23d6..4cc342c50 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -695,5 +695,16 @@ "voice.stopDictation": "Hagyd abba a diktálást", "voice.stopListening": "Ne hallgasson", "worlds.emptyState.description": "Építsd fel azokat a helyeket, szabályokat és történeteket, amelyekben a történeted él. Kezdd egy hellyel.", - "worlds.emptyState.title": "A világ vár" + "worlds.emptyState.title": "A világ vár", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings." } diff --git a/locales/is/common.json b/locales/is/common.json index 95b86efd3..40d07e09b 100644 --- a/locales/is/common.json +++ b/locales/is/common.json @@ -695,5 +695,16 @@ "voice.stopDictation": "Hættu einræði", "voice.stopListening": "Hættu að hlusta", "worlds.emptyState.description": "Búðu til staðina, reglurnar og söguna sem sagan þín býr í. Byrjaðu á staðsetningu.", - "worlds.emptyState.title": "Heimurinn bíður" + "worlds.emptyState.title": "Heimurinn bíður", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings." } diff --git a/locales/it/common.json b/locales/it/common.json index cfa715895..426895beb 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -695,5 +695,16 @@ "voice.stopDictation": "Ferma dettatura", "voice.stopListening": "Ferma ascolto", "worlds.emptyState.description": "Costruisci i luoghi, le regole e le storie in cui vive la tua narrazione. Inizia con una location.", - "worlds.emptyState.title": "Il mondo ti aspetta" + "worlds.emptyState.title": "Il mondo ti aspetta", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings." } diff --git a/locales/ja/common.json b/locales/ja/common.json index 65af5ab70..1f21de9eb 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -695,5 +695,16 @@ "voice.stopDictation": "ディクテーションを停止する", "voice.stopListening": "聞くのをやめる", "worlds.emptyState.description": "あなたの物語が生きる場所、ルール、歴史を構築します。場所から始めます。", - "worlds.emptyState.title": "世界が待っています" + "worlds.emptyState.title": "世界が待っています", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings." } diff --git a/locales/ko/common.json b/locales/ko/common.json index c35d294bb..9d4c589b8 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -695,5 +695,16 @@ "voice.stopDictation": "받아쓰기 중지", "voice.stopListening": "듣기 중지", "worlds.emptyState.description": "당신의 이야기가 담긴 장소, 규칙, 역사를 만들어 보세요. 위치부터 시작하세요.", - "worlds.emptyState.title": "세계가 기다리고 있다" + "worlds.emptyState.title": "세계가 기다리고 있다", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings." } diff --git a/locales/pt/common.json b/locales/pt/common.json index 8e9994a3c..34d16941f 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -695,5 +695,16 @@ "voice.stopDictation": "Pare o ditado", "voice.stopListening": "Pare de ouvir", "worlds.emptyState.description": "Construa os lugares, regras e histórias em que sua história vive. Comece com um local.", - "worlds.emptyState.title": "O mundo espera" + "worlds.emptyState.title": "O mundo espera", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings." } diff --git a/locales/ru/common.json b/locales/ru/common.json index adbed0250..ca2adb1d9 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -695,5 +695,16 @@ "voice.stopDictation": "Остановить диктовку", "voice.stopListening": "Хватит слушать", "worlds.emptyState.description": "Создайте места, правила и историю, в которых живет ваша история. Начните с локации.", - "worlds.emptyState.title": "Мир ждет" + "worlds.emptyState.title": "Мир ждет", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings." } diff --git a/locales/sv/common.json b/locales/sv/common.json index 7e82b08fa..abf1ff6c5 100644 --- a/locales/sv/common.json +++ b/locales/sv/common.json @@ -695,5 +695,16 @@ "voice.stopDictation": "Sluta diktera", "voice.stopListening": "Sluta lyssna", "worlds.emptyState.description": "Bygg upp platserna, reglerna och historien som din berättelse lever i. Börja med en plats.", - "worlds.emptyState.title": "Världen väntar" + "worlds.emptyState.title": "Världen väntar", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings." } diff --git a/locales/zh/common.json b/locales/zh/common.json index 972777528..ab4e68925 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -695,5 +695,16 @@ "voice.stopDictation": "停止听写", "voice.stopListening": "停止聆听", "worlds.emptyState.description": "构建你的故事所存在的地点、规则和历史。从一个地点开始。", - "worlds.emptyState.title": "世界等待着" + "worlds.emptyState.title": "世界等待着", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings." } diff --git a/public/locales/ar/bundle.json b/public/locales/ar/bundle.json index 286c1452e..ed60cb62a 100644 --- a/public/locales/ar/bundle.json +++ b/public/locales/ar/bundle.json @@ -812,6 +812,17 @@ "voice.stopListening": "إيقاف الاستماع", "worlds.emptyState.description": "ابنِ الأماكن والقواعد والتواريخ التي تعيش فيها قصتك. ابدأ بموقع.", "worlds.emptyState.title": "العالم بانتظارك", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings.", "copilot.annotationCount": "{{count}} insight for this chapter", "copilot.announceClosed": "AI Copilot closed", "copilot.announceOpened": "AI Copilot opened", diff --git a/public/locales/de/bundle.json b/public/locales/de/bundle.json index c4e75899d..0781d6a84 100644 --- a/public/locales/de/bundle.json +++ b/public/locales/de/bundle.json @@ -812,6 +812,17 @@ "voice.stopListening": "Zuhören stoppen", "worlds.emptyState.description": "Baue die Orte, Regeln und Geschichten auf, in denen deine Geschichte lebt. Beginne mit einem Ort.", "worlds.emptyState.title": "Die Welt wartet", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings.", "copilot.annotationCount": "{{count}} insight for this chapter", "copilot.announceClosed": "KI-Copilot geschlossen", "copilot.announceOpened": "KI-Copilot geöffnet", diff --git a/public/locales/el/bundle.json b/public/locales/el/bundle.json index 27ef72a23..af0379736 100644 --- a/public/locales/el/bundle.json +++ b/public/locales/el/bundle.json @@ -812,6 +812,17 @@ "voice.stopListening": "Σταμάτα να ακούς", "worlds.emptyState.description": "Δημιουργήστε τα μέρη, τους κανόνες και τις ιστορίες στα οποία ζει η ιστορία σας. Ξεκινήστε με μια τοποθεσία.", "worlds.emptyState.title": "Ο κόσμος περιμένει", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings.", "copilot.annotationCount": "{{count}} πληροφορίες για αυτό το κεφάλαιο", "copilot.announceClosed": "Το AI Copilot έκλεισε", "copilot.announceOpened": "Άνοιξε το AI Copilot", diff --git a/public/locales/en/bundle.json b/public/locales/en/bundle.json index dfea22b2a..a4f39e215 100644 --- a/public/locales/en/bundle.json +++ b/public/locales/en/bundle.json @@ -812,6 +812,17 @@ "plotBoard.heuristic.reverse.title": "Add a reversal", "plotBoard.heuristic.reverse.description": "Flip an expectation — an ally falters, a win turns sour, or a hidden truth surfaces.", "plotBoard.heuristic.reverse.rationale": "Reversals refresh tension and deepen the arc.", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings.", "copilot.title": "AI Copilot", "copilot.subtitle": "Your writing assistant", "copilot.launcherLabel": "Open AI Copilot", diff --git a/public/locales/es/bundle.json b/public/locales/es/bundle.json index a9eaebea9..dbf5d08e0 100644 --- a/public/locales/es/bundle.json +++ b/public/locales/es/bundle.json @@ -812,6 +812,17 @@ "voice.stopListening": "Detener escucha", "worlds.emptyState.description": "Construye los lugares, reglas e historias en los que vive tu historia. Comienza con una ubicación.", "worlds.emptyState.title": "El mundo te espera", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings.", "copilot.annotationCount": "{{count}} insight for this chapter", "copilot.announceClosed": "Copiloto IA cerrado", "copilot.announceOpened": "Copiloto IA abierto", diff --git a/public/locales/eu/bundle.json b/public/locales/eu/bundle.json index 7b6c5a74c..41a2aed0f 100644 --- a/public/locales/eu/bundle.json +++ b/public/locales/eu/bundle.json @@ -812,6 +812,17 @@ "voice.stopListening": "Utzi entzuteari", "worlds.emptyState.description": "Eraiki zure istorioa bizi den lekuak, arauak eta historiak. Hasi kokapen batekin.", "worlds.emptyState.title": "Mundua zain dago", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings.", "copilot.annotationCount": "{{count}} kapitulu honetarako ikuspegia", "copilot.announceClosed": "AI Copilot itxita", "copilot.announceOpened": "AI Copilot ireki da", diff --git a/public/locales/fa/bundle.json b/public/locales/fa/bundle.json index ec94a5385..8273619f6 100644 --- a/public/locales/fa/bundle.json +++ b/public/locales/fa/bundle.json @@ -812,6 +812,17 @@ "voice.stopListening": "گوش دادن را متوقف کنید", "worlds.emptyState.description": "مکان‌ها، قوانین و تاریخ‌هایی را بسازید که داستانتان در آن زندگی می‌کند. با یک مکان شروع کنید.", "worlds.emptyState.title": "دنیا منتظر است", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings.", "copilot.annotationCount": "{{count}} بینش برای این فصل", "copilot.announceClosed": "AI Copilot بسته شد", "copilot.announceOpened": "AI Copilot باز شد", diff --git a/public/locales/fi/bundle.json b/public/locales/fi/bundle.json index e57df0d80..cddf681c8 100644 --- a/public/locales/fi/bundle.json +++ b/public/locales/fi/bundle.json @@ -812,6 +812,17 @@ "voice.stopListening": "Lopeta kuunteleminen", "worlds.emptyState.description": "Rakenna paikat, säännöt ja historiat, joissa tarinasi elää. Aloita sijainnista.", "worlds.emptyState.title": "Maailma odottaa", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings.", "copilot.annotationCount": "{{count}} tietoa tästä luvusta", "copilot.announceClosed": "AI Copilot suljettu", "copilot.announceOpened": "AI Copilot avattiin", diff --git a/public/locales/fr/bundle.json b/public/locales/fr/bundle.json index 22d930b1d..586137933 100644 --- a/public/locales/fr/bundle.json +++ b/public/locales/fr/bundle.json @@ -812,6 +812,17 @@ "voice.stopListening": "Arrêter l’écoute", "worlds.emptyState.description": "Construisez les lieux, les règles et les histoires dans lesquels votre récit prend vie. Commencez par un lieu.", "worlds.emptyState.title": "Le monde vous attend", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings.", "copilot.annotationCount": "{{count}} insight for this chapter", "copilot.announceClosed": "Copilot IA fermé", "copilot.announceOpened": "Copilot IA ouvert", diff --git a/public/locales/he/bundle.json b/public/locales/he/bundle.json index 5c6d014a1..b2390bf95 100644 --- a/public/locales/he/bundle.json +++ b/public/locales/he/bundle.json @@ -812,6 +812,17 @@ "voice.stopListening": "עצירת האזנה", "worlds.emptyState.description": "בנו את המקומות, החוקים וההיסטוריות שבהם הסיפור שלכם חי. התחילו במיקום.", "worlds.emptyState.title": "העולם ממתין", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings.", "copilot.annotationCount": "{{count}} insight for this chapter", "copilot.announceClosed": "AI Copilot closed", "copilot.announceOpened": "AI Copilot opened", diff --git a/public/locales/hu/bundle.json b/public/locales/hu/bundle.json index f779a2cea..347ca0acb 100644 --- a/public/locales/hu/bundle.json +++ b/public/locales/hu/bundle.json @@ -812,6 +812,17 @@ "voice.stopListening": "Ne hallgasson", "worlds.emptyState.description": "Építsd fel azokat a helyeket, szabályokat és történeteket, amelyekben a történeted él. Kezdd egy hellyel.", "worlds.emptyState.title": "A világ vár", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings.", "copilot.annotationCount": "{{count}} betekintést nyújt ehhez a fejezethez", "copilot.announceClosed": "Az AI másodpilóta zárva", "copilot.announceOpened": "Az AI másodpilóta megnyílt", diff --git a/public/locales/is/bundle.json b/public/locales/is/bundle.json index 9ba1aad4f..63279a254 100644 --- a/public/locales/is/bundle.json +++ b/public/locales/is/bundle.json @@ -812,6 +812,17 @@ "voice.stopListening": "Hættu að hlusta", "worlds.emptyState.description": "Búðu til staðina, reglurnar og söguna sem sagan þín býr í. Byrjaðu á staðsetningu.", "worlds.emptyState.title": "Heimurinn bíður", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings.", "copilot.annotationCount": "{{count}} innsýn fyrir þennan kafla", "copilot.announceClosed": "AI Copilot lokað", "copilot.announceOpened": "AI Copilot opnaður", diff --git a/public/locales/it/bundle.json b/public/locales/it/bundle.json index 6e6a5c7f7..cfad21959 100644 --- a/public/locales/it/bundle.json +++ b/public/locales/it/bundle.json @@ -812,6 +812,17 @@ "voice.stopListening": "Ferma ascolto", "worlds.emptyState.description": "Costruisci i luoghi, le regole e le storie in cui vive la tua narrazione. Inizia con una location.", "worlds.emptyState.title": "Il mondo ti aspetta", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings.", "copilot.annotationCount": "{{count}} insight for this chapter", "copilot.announceClosed": "Copilota IA chiuso", "copilot.announceOpened": "Copilota IA aperto", diff --git a/public/locales/ja/bundle.json b/public/locales/ja/bundle.json index 58e4b9ac1..3492fe574 100644 --- a/public/locales/ja/bundle.json +++ b/public/locales/ja/bundle.json @@ -812,6 +812,17 @@ "voice.stopListening": "聞くのをやめる", "worlds.emptyState.description": "あなたの物語が生きる場所、ルール、歴史を構築します。場所から始めます。", "worlds.emptyState.title": "世界が待っています", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings.", "copilot.annotationCount": "{{count}} この章の洞察", "copilot.announceClosed": "AI コパイロットは終了しました", "copilot.announceOpened": "AI Copilot がオープンしました", diff --git a/public/locales/ko/bundle.json b/public/locales/ko/bundle.json index 0f329e17f..9deba8de3 100644 --- a/public/locales/ko/bundle.json +++ b/public/locales/ko/bundle.json @@ -812,6 +812,17 @@ "voice.stopListening": "듣기 중지", "worlds.emptyState.description": "당신의 이야기가 담긴 장소, 규칙, 역사를 만들어 보세요. 위치부터 시작하세요.", "worlds.emptyState.title": "세계가 기다리고 있다", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings.", "copilot.annotationCount": "{{count}} 이 장에 대한 통찰력", "copilot.announceClosed": "AI 부조종사 폐쇄", "copilot.announceOpened": "AI 코파일럿 오픈", diff --git a/public/locales/pt/bundle.json b/public/locales/pt/bundle.json index f451d8c25..9591091a2 100644 --- a/public/locales/pt/bundle.json +++ b/public/locales/pt/bundle.json @@ -812,6 +812,17 @@ "voice.stopListening": "Pare de ouvir", "worlds.emptyState.description": "Construa os lugares, regras e histórias em que sua história vive. Comece com um local.", "worlds.emptyState.title": "O mundo espera", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings.", "copilot.annotationCount": "{{count}} visão para este capítulo", "copilot.announceClosed": "Copiloto AI fechado", "copilot.announceOpened": "Copiloto AI aberto", diff --git a/public/locales/ru/bundle.json b/public/locales/ru/bundle.json index 9bfa0ea85..74ede201d 100644 --- a/public/locales/ru/bundle.json +++ b/public/locales/ru/bundle.json @@ -812,6 +812,17 @@ "voice.stopListening": "Хватит слушать", "worlds.emptyState.description": "Создайте места, правила и историю, в которых живет ваша история. Начните с локации.", "worlds.emptyState.title": "Мир ждет", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings.", "copilot.annotationCount": "{{count}} информация по этой главе", "copilot.announceClosed": "AI второй пилот закрыт", "copilot.announceOpened": "AI второй пилот открыт", diff --git a/public/locales/sv/bundle.json b/public/locales/sv/bundle.json index edab8844a..7cf90ee13 100644 --- a/public/locales/sv/bundle.json +++ b/public/locales/sv/bundle.json @@ -812,6 +812,17 @@ "voice.stopListening": "Sluta lyssna", "worlds.emptyState.description": "Bygg upp platserna, reglerna och historien som din berättelse lever i. Börja med en plats.", "worlds.emptyState.title": "Världen väntar", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings.", "copilot.annotationCount": "{{count}} insikt för detta kapitel", "copilot.announceClosed": "AI Copilot stängd", "copilot.announceOpened": "AI Copilot öppnade", diff --git a/public/locales/zh/bundle.json b/public/locales/zh/bundle.json index a8c487bc5..4cc10ab14 100644 --- a/public/locales/zh/bundle.json +++ b/public/locales/zh/bundle.json @@ -812,6 +812,17 @@ "voice.stopListening": "停止聆听", "worlds.emptyState.description": "构建你的故事所存在的地点、规则和历史。从一个地点开始。", "worlds.emptyState.title": "世界等待着", + "error.startup.description": "The local project or database could not be opened. Reload and try again.", + "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", + "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.reload": "Reload", + "error.startup.recover": "Quarantine project and reload", + "error.startup.recovering": "Preserving project…", + "error.startup.reset": "Reset database and reload", + "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", + "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", + "error.startup.resetWarning": "Resetting the database will delete all local projects and settings.", "copilot.annotationCount": "{{count}} 本章见解", "copilot.announceClosed": "AI副驾驶关闭", "copilot.announceOpened": "AI副驾驶开启", diff --git a/services/fs/projectFsStore.ts b/services/fs/projectFsStore.ts index 57e065b6f..6cfcef46f 100644 --- a/services/fs/projectFsStore.ts +++ b/services/fs/projectFsStore.ts @@ -36,6 +36,15 @@ export class ProjectLoadError extends Error { } } +export class ProjectQuarantineError extends Error { + constructor( + public readonly reason: 'not-found' | 'io-error' | 'name-exhausted' | 'already-preserved', + ) { + super('Project preservation failed. The original project was not deleted.'); + this.name = 'ProjectQuarantineError'; + } +} + // QNBS-v3 (CodeAnt/CodeRabbit): array-or-EntityState — characters/worlds may be either shape in a real saved project. function isArrayOrEntityState(value: unknown): boolean { if (Array.isArray(value)) return true; @@ -193,32 +202,61 @@ export class FsProjectStore extends FsAssetStore { // QNBS-v3: move the whole folder before reload so corrupt project artifacts remain recoverable. /** Move the whole corrupt project directory aside so its manuscript and assets remain recoverable. */ async quarantineProject(projectId: string): Promise { - const apis = await this.getApis(); - const appDataPath = await this.ensureAppDataPath(); - const safeProjectId = sanitizePathSegment(projectId, 'project'); - const projectPath = await apis.join(appDataPath, 'projects', safeProjectId); - if (!(await apis.exists(projectPath))) { - throw new Error(`Could not quarantine project "${projectId}": project directory not found.`); - } + try { + const apis = await this.getApis(); + const appDataPath = await this.ensureAppDataPath(); + const safeProjectId = sanitizePathSegment(projectId, 'project'); + const projectPath = await apis.join(appDataPath, 'projects', safeProjectId); + if (!(await apis.exists(projectPath))) { + throw new ProjectQuarantineError('not-found'); + } - const quarantineRoot = await apis.join(appDataPath, 'quarantined-projects'); - await apis.mkdir(quarantineRoot, { recursive: true }); - const timestamp = Date.now(); - for (let attempt = 0; attempt < 100; attempt++) { - const suffix = attempt === 0 ? String(timestamp) : `${timestamp}-${attempt}`; - const quarantinePath = await apis.join(quarantineRoot, `${safeProjectId}-corrupt-${suffix}`); - if (await apis.exists(quarantinePath)) continue; - try { - await retryFs(() => apis.rename(projectPath, quarantinePath)); - return { projectId, path: quarantinePath }; - } catch (error) { - // QNBS-v3: a concurrent quarantine may claim the checked target between exists and rename. + const quarantineRoot = await apis.join(appDataPath, 'quarantined-projects'); + await apis.mkdir(quarantineRoot, { recursive: true }); + const timestamp = Date.now(); + for (let attempt = 0; attempt < 100; attempt++) { + const suffix = attempt === 0 ? String(timestamp) : `${timestamp}-${attempt}`; + const quarantinePath = await apis.join( + quarantineRoot, + `${safeProjectId}-corrupt-${suffix}`, + ); if (await apis.exists(quarantinePath)) continue; - throw error; + try { + await retryFs(() => apis.rename(projectPath, quarantinePath)); + return { projectId, path: quarantinePath }; + } catch (error) { + // QNBS-v3: a concurrent quarantine may claim the checked target between exists and rename. + let targetExists: boolean; + let sourceExists: boolean; + try { + targetExists = await apis.exists(quarantinePath); + sourceExists = await apis.exists(projectPath); + } catch (probeError) { + logger.error('Failed to inspect a concurrent quarantine result', { + projectId, + error: probeError instanceof Error ? probeError.message : String(probeError), + }); + throw new ProjectQuarantineError('io-error'); + } + if (targetExists) continue; + if (!sourceExists) throw new ProjectQuarantineError('already-preserved'); + logger.error('Failed to quarantine project directory', { + projectId, + error: error instanceof Error ? error.message : String(error), + }); + throw new ProjectQuarantineError('io-error'); + } } - } - throw new Error(`Could not quarantine project "${projectId}": recovery target already exists.`); + throw new ProjectQuarantineError('name-exhausted'); + } catch (error) { + if (error instanceof ProjectQuarantineError) throw error; + logger.error('Failed to prepare project quarantine', { + projectId, + error: error instanceof Error ? error.message : String(error), + }); + throw new ProjectQuarantineError('io-error'); + } } async deleteProject(projectId: string): Promise { diff --git a/services/startupRecovery.tsx b/services/startupRecovery.tsx new file mode 100644 index 000000000..d0283a3d8 --- /dev/null +++ b/services/startupRecovery.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import type { Root } from 'react-dom/client'; +import { loadStorageErrorCopy, StorageErrorScreen } from '../components/StorageErrorScreen'; +import { resetAllDatabases } from './dbInitialization'; +import { ProjectLoadError, ProjectQuarantineError } from './fs/projectFsStore'; +import { logger } from './logger'; +import { getStartupRecoveryActions } from './startupRecoveryPolicy'; +import { storageService } from './storageService'; + +// QNBS-v3: keep startup recovery render decisions testable so destructive reset stays bound to backend provenance. +export async function renderStorageInitializationFailure(root: Root): Promise { + const copy = await loadStorageErrorCopy(); + root.render( + + { + await resetAllDatabases(); + window.location.reload(); + }} + /> + , + ); +} + +// QNBS-v3: filesystem failures get preserve-first actions while only IndexedDB failures retain destructive reset authority. +export async function renderProjectInitializationFailure( + root: Root, + error: unknown, +): Promise { + logger.error('Failed to initialize the application:', error); + const projectLoadError = error instanceof ProjectLoadError ? error : null; + const backendKind = await storageService.getStorageBackendKind(); + const copy = await loadStorageErrorCopy(); + const { canQuarantine, canReset } = getStartupRecoveryActions(error, backendKind); + const corruptProjectId = + projectLoadError?.reason === 'corrupt' ? projectLoadError.projectId : null; + root.render( + + { + if (!corruptProjectId) throw new ProjectQuarantineError('io-error'); + const result = await storageService.quarantineProject(corruptProjectId); + if (!result) throw new ProjectQuarantineError('io-error'); + window.location.reload(); + }, + } + : {})} + {...(canReset + ? { + onReset: async () => { + await resetAllDatabases(); + window.location.reload(); + }, + } + : {})} + /> + , + ); +} diff --git a/services/startupRecoveryPolicy.ts b/services/startupRecoveryPolicy.ts new file mode 100644 index 000000000..4fede0c95 --- /dev/null +++ b/services/startupRecoveryPolicy.ts @@ -0,0 +1,15 @@ +import { ProjectLoadError } from './fs/projectFsStore'; + +export type StartupStorageBackend = 'indexeddb' | 'filesystem'; + +// QNBS-v3: bind destructive reset to IndexedDB provenance so desktop filesystem failures stay preserve-first. +export function getStartupRecoveryActions( + error: unknown, + backend: StartupStorageBackend, +): { canQuarantine: boolean; canReset: boolean } { + const projectLoadError = error instanceof ProjectLoadError ? error : null; + return { + canQuarantine: projectLoadError?.reason === 'corrupt' && backend === 'filesystem', + canReset: !projectLoadError && backend === 'indexeddb', + }; +} diff --git a/services/storageBackend.ts b/services/storageBackend.ts index 899f24dcb..6ac868692 100644 --- a/services/storageBackend.ts +++ b/services/storageBackend.ts @@ -13,6 +13,7 @@ export interface BinderAssetPayload { meta: BinderAssetMeta; } +// QNBS-v3: shared result keeps the affected identity and verified quarantine path explicit across backends. /** Result of moving a corrupt desktop project out of the active project namespace. */ export interface ProjectQuarantineResult { projectId: string; @@ -72,7 +73,7 @@ export interface StorageBackend { deleteProject(projectId: string): Promise; /** QNBS-v3 (#332): optional — only the multi-project Tauri filesystem backend implements this; IndexedDB's single-project contract has no "which one" ambiguity to resolve. */ getActiveProjectId?(): Promise; - /** Desktop-only preserve-first recovery; IndexedDB has no filesystem project to quarantine. */ + // QNBS-v3: optional recovery keeps the filesystem-only quarantine contract out of IndexedDB. quarantineProject?(projectId: string): Promise; saveImage(id: string, base64Data: string): Promise; diff --git a/services/storageService.ts b/services/storageService.ts index a1670b29c..1a5d93bb2 100644 --- a/services/storageService.ts +++ b/services/storageService.ts @@ -91,6 +91,7 @@ class StorageManager { return (await backend.getActiveProjectId?.()) ?? null; } + // QNBS-v3: delegate supported desktop quarantine and normalize unsupported backends to null. async quarantineProject(projectId: string): Promise { const backend = await this.getBackend(); return (await backend.quarantineProject?.(projectId)) ?? null; diff --git a/tests/unit/StorageErrorScreen.test.tsx b/tests/unit/StorageErrorScreen.test.tsx new file mode 100644 index 000000000..38a5de115 --- /dev/null +++ b/tests/unit/StorageErrorScreen.test.tsx @@ -0,0 +1,103 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + loadStorageErrorCopy, + STARTUP_COPY_FALLBACKS, + StorageErrorScreen, +} from '../../components/StorageErrorScreen'; +import { ProjectQuarantineError } from '../../services/fs/projectFsStore'; + +const { getStaticTranslation, loggerError } = vi.hoisted(() => ({ + getStaticTranslation: vi.fn(), + loggerError: vi.fn(), +})); + +vi.mock('../../services/i18n/staticTranslate', () => ({ getStaticTranslation })); +vi.mock('../../services/logger', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + logger: { debug: vi.fn(), warn: vi.fn(), info: vi.fn(), error: loggerError }, + }; +}); + +describe('StorageErrorScreen', () => { + beforeEach(() => { + vi.clearAllMocks(); + getStaticTranslation.mockImplementation((key: string) => Promise.resolve(`translated:${key}`)); + }); + + it('loads translated recovery copy and falls back safely when a key is unavailable', async () => { + await expect(loadStorageErrorCopy()).resolves.toMatchObject({ + description: 'translated:error.startup.description', + recoveryAlreadyPreserved: 'translated:error.startup.recoveryAlreadyPreserved', + }); + + getStaticTranslation.mockImplementation((key: string) => Promise.resolve(key)); + await expect(loadStorageErrorCopy()).resolves.toEqual(STARTUP_COPY_FALLBACKS); + }); + + it('renders localized actions and never renders a raw recovery exception', async () => { + const user = userEvent.setup(); + const onRecover = vi.fn().mockRejectedValue(new Error('EACCES /private/project.json')); + const copy = { + ...STARTUP_COPY_FALLBACKS, + description: 'Localized description', + projectUnavailable: 'Localized project failure', + recover: 'Localized preserve', + recoveryFailed: 'Localized safe failure', + }; + + render(); + expect(screen.getByText('Localized description')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Localized preserve' })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Localized preserve' })); + + expect(await screen.findByText('Localized safe failure')).toBeInTheDocument(); + expect(screen.queryByText('EACCES /private/project.json')).not.toBeInTheDocument(); + expect(loggerError).toHaveBeenCalledWith( + 'Project quarantine failed', + expect.objectContaining({ error: 'EACCES /private/project.json' }), + ); + }); + + it('reports the typed already-preserved outcome and supports successful recovery and reset actions', async () => { + const user = userEvent.setup(); + const onRecover = vi.fn().mockRejectedValue(new ProjectQuarantineError('already-preserved')); + const onReset = vi.fn(); + const firstRender = render( + , + ); + + await user.click(screen.getByRole('button', { name: STARTUP_COPY_FALLBACKS.recover })); + expect( + await screen.findByText(STARTUP_COPY_FALLBACKS.recoveryAlreadyPreserved), + ).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: STARTUP_COPY_FALLBACKS.reset })); + expect(onReset).toHaveBeenCalledOnce(); + + firstRender.unmount(); + const successfulRecover = vi.fn().mockResolvedValue(undefined); + render(); + await user.click(screen.getByRole('button', { name: STARTUP_COPY_FALLBACKS.recover })); + await waitFor(() => expect(successfulRecover).toHaveBeenCalledOnce()); + expect( + screen.queryByRole('button', { name: STARTUP_COPY_FALLBACKS.reset }), + ).not.toBeInTheDocument(); + }); + + it('omits unsupported recovery actions when no handlers are provided', () => { + render(); + + expect( + screen.queryByRole('button', { name: STARTUP_COPY_FALLBACKS.recover }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: STARTUP_COPY_FALLBACKS.reset }), + ).not.toBeInTheDocument(); + expect(screen.getByText(STARTUP_COPY_FALLBACKS.storageUnavailable)).toBeInTheDocument(); + }); +}); diff --git a/tests/unit/services/fs/fsStores.test.ts b/tests/unit/services/fs/fsStores.test.ts index 4b842300d..431e8fa82 100644 --- a/tests/unit/services/fs/fsStores.test.ts +++ b/tests/unit/services/fs/fsStores.test.ts @@ -200,6 +200,7 @@ describe('FsProjectStore — projects', () => { await expect(store.loadProject('p1')).resolves.toBeNull(); }); + // QNBS-v3: prove a claimed quarantine target cannot turn preserve-first recovery into data loss. it('tries the next quarantine name when a concurrent rename claims the checked target', async () => { await store.saveProject(project as never); const originalRename = fake.apis.rename; @@ -222,12 +223,31 @@ describe('FsProjectStore — projects', () => { expect(fake.text.has('/app/projects/p1/project.json')).toBe(false); }); + // QNBS-v3: report source disappearance as concurrent preservation, without inventing a quarantine path. + it('reports when another recovery moved the source before this rename', async () => { + await store.saveProject(project as never); + fake.apis.rename = async (from: string) => { + await fake.apis.remove(from); + throw new Error(`ENOENT ${from}`); + }; + + await expect(store.quarantineProject('p1')).rejects.toMatchObject({ + name: 'ProjectQuarantineError', + reason: 'already-preserved', + }); + expect(await store.listProjects()).not.toContain('p1'); + }); + it('leaves the original project intact when quarantine cannot rename it', async () => { await store.saveProject(project as never); const original = fake.text.get('/app/projects/p1/project.json'); fake.apis.rename = () => Promise.reject(new Error('EACCES: permission denied')); - await expect(store.quarantineProject('p1')).rejects.toThrow('EACCES'); + await expect(store.quarantineProject('p1')).rejects.toMatchObject({ + name: 'ProjectQuarantineError', + reason: 'io-error', + message: 'Project preservation failed. The original project was not deleted.', + }); expect(fake.text.get('/app/projects/p1/project.json')).toBe(original); expect(await store.listProjects()).toContain('p1'); diff --git a/tests/unit/services/fs/projectFsStore.test.ts b/tests/unit/services/fs/projectFsStore.test.ts index 220cd7425..2c47459d8 100644 --- a/tests/unit/services/fs/projectFsStore.test.ts +++ b/tests/unit/services/fs/projectFsStore.test.ts @@ -52,6 +52,7 @@ describe('FsProjectStore.loadProject — DA-01 fail-closed behavior', () => { await expect(store.loadProject('missing-id')).resolves.toBeNull(); }); + // QNBS-v3: preserve the affected project identity when filesystem reads fail during recovery. it('throws ProjectLoadError("io-error") on a read failure instead of returning null', async () => { const { FsProjectStore, ProjectLoadError } = await import( '../../../../services/fs/projectFsStore' @@ -66,6 +67,7 @@ describe('FsProjectStore.loadProject — DA-01 fail-closed behavior', () => { await expect(promise).rejects.toMatchObject({ reason: 'io-error', projectId: 'locked-id' }); }); + // QNBS-v3: keep corruption classification attached to the project so quarantine targets only it. it('throws ProjectLoadError("corrupt") on a corrupt/truncated compressed payload', async () => { const { FsProjectStore, ProjectLoadError } = await import( '../../../../services/fs/projectFsStore' diff --git a/tests/unit/startupRecovery.test.tsx b/tests/unit/startupRecovery.test.tsx new file mode 100644 index 000000000..af5b28955 --- /dev/null +++ b/tests/unit/startupRecovery.test.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +const { mockRoot, mockReset, mockBackendKind, mockQuarantine, mockCopy, loggerError } = vi.hoisted( + () => ({ + mockRoot: { render: vi.fn() }, + mockReset: vi.fn().mockResolvedValue(undefined), + mockBackendKind: vi.fn(), + mockQuarantine: vi.fn(), + mockCopy: { + description: 'description', + storageUnavailable: 'storage unavailable', + projectUnavailable: 'project unavailable', + reload: 'reload', + recover: 'recover', + recovering: 'recovering', + reset: 'reset', + quarantineNotice: 'quarantine notice', + recoveryFailed: 'recovery failed', + recoveryAlreadyPreserved: 'already preserved', + resetWarning: 'reset warning', + }, + loggerError: vi.fn(), + }), +); + +vi.mock('../../services/dbInitialization', () => ({ resetAllDatabases: mockReset })); +vi.mock('../../services/storageService', () => ({ + storageService: { + getStorageBackendKind: mockBackendKind, + quarantineProject: mockQuarantine, + }, +})); +vi.mock('../../services/i18n/staticTranslate', () => ({ + getStaticTranslation: (key: string) => Promise.resolve(key), +})); +vi.mock('../../services/logger', () => ({ + logger: { debug: vi.fn(), warn: vi.fn(), info: vi.fn(), error: loggerError }, +})); +vi.mock('../../components/StorageErrorScreen', () => ({ + loadStorageErrorCopy: () => Promise.resolve(mockCopy), + StorageErrorScreen: () => React.createElement('div'), +})); +vi.mock('../../services/fs/projectFsStore', () => { + class ProjectLoadError extends Error { + constructor( + public readonly reason: 'corrupt' | 'io-error', + message: string, + public readonly projectId: string, + ) { + super(message); + this.name = 'ProjectLoadError'; + } + } + class ProjectQuarantineError extends Error { + constructor(public readonly reason: string) { + super('safe quarantine failure'); + this.name = 'ProjectQuarantineError'; + } + } + return { ProjectLoadError, ProjectQuarantineError }; +}); + +import { ProjectLoadError } from '../../services/fs/projectFsStore'; +import { + renderProjectInitializationFailure, + renderStorageInitializationFailure, +} from '../../services/startupRecovery'; + +type RecoveryScreenProps = { + copy: typeof mockCopy; + onReset?: () => Promise; + onRecover?: () => Promise; +}; + +function renderedScreenProps(): RecoveryScreenProps { + const strictMode = mockRoot.render.mock.calls.at(-1)?.[0] as React.ReactElement<{ + children: React.ReactElement; + }>; + return strictMode.props.children.props; +} + +describe('startup recovery rendering', () => { + it('renders the database reset screen when IndexedDB initialization fails', async () => { + await renderStorageInitializationFailure(mockRoot as never); + + expect(mockRoot.render).toHaveBeenCalledOnce(); + expect(renderedScreenProps().copy).toBe(mockCopy); + expect(renderedScreenProps().onReset).toEqual(expect.any(Function)); + }); + + it('renders quarantine for corrupt filesystem projects and preserves the exact project ID', async () => { + mockBackendKind.mockResolvedValue('filesystem'); + mockQuarantine.mockResolvedValue({ projectId: 'p1', path: '/quarantine/p1' }); + await renderProjectInitializationFailure( + mockRoot as never, + new ProjectLoadError('corrupt', 'corrupt', 'p1'), + ); + + const props = renderedScreenProps(); + expect(props.onRecover).toEqual(expect.any(Function)); + expect(props.onReset).toBeUndefined(); + await (props.onRecover as () => Promise)(); + expect(mockQuarantine).toHaveBeenCalledWith('p1'); + }); + + it('does not expose reset for raw or project filesystem failures', async () => { + mockBackendKind.mockResolvedValue('filesystem'); + + await renderProjectInitializationFailure(mockRoot as never, new Error('EACCES /projects/p1')); + expect(renderedScreenProps().onReset).toBeUndefined(); + + await renderProjectInitializationFailure( + mockRoot as never, + new ProjectLoadError('io-error', 'io', 'p1'), + ); + expect(renderedScreenProps().onReset).toBeUndefined(); + expect(loggerError).toHaveBeenCalled(); + }); + + it('retains reset only for a non-project IndexedDB failure', async () => { + mockBackendKind.mockResolvedValue('indexeddb'); + await renderProjectInitializationFailure(mockRoot as never, new Error('QuotaExceededError')); + + expect(renderedScreenProps().onRecover).toBeUndefined(); + expect(renderedScreenProps().onReset).toEqual(expect.any(Function)); + }); +}); diff --git a/tests/unit/startupRecoveryPolicy.test.ts b/tests/unit/startupRecoveryPolicy.test.ts new file mode 100644 index 000000000..e0e574c22 --- /dev/null +++ b/tests/unit/startupRecoveryPolicy.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { ProjectLoadError } from '../../services/fs/projectFsStore'; +import { getStartupRecoveryActions } from '../../services/startupRecoveryPolicy'; + +describe('startup recovery action policy', () => { + it('offers quarantine only for corrupt projects on the filesystem backend', () => { + const error = new ProjectLoadError('corrupt', 'corrupt', 'project-1'); + + expect(getStartupRecoveryActions(error, 'filesystem')).toEqual({ + canQuarantine: true, + canReset: false, + }); + expect(getStartupRecoveryActions(error, 'indexeddb')).toEqual({ + canQuarantine: false, + canReset: false, + }); + }); + + it('never offers destructive reset for filesystem-origin failures, including raw errors', () => { + expect(getStartupRecoveryActions(new Error('EACCES /projects/p1'), 'filesystem')).toEqual({ + canQuarantine: false, + canReset: false, + }); + expect( + getStartupRecoveryActions(new ProjectLoadError('io-error', 'io', 'project-1'), 'filesystem'), + ).toEqual({ + canQuarantine: false, + canReset: false, + }); + }); + + it('retains database reset for non-project failures from IndexedDB', () => { + expect(getStartupRecoveryActions(new Error('QuotaExceededError'), 'indexeddb')).toEqual({ + canQuarantine: false, + canReset: true, + }); + }); +}); diff --git a/tests/unit/storageService.test.ts b/tests/unit/storageService.test.ts index f0b4b4f80..7bb5fb7af 100644 --- a/tests/unit/storageService.test.ts +++ b/tests/unit/storageService.test.ts @@ -90,6 +90,11 @@ describe('storageService (IndexedDB backend in browser)', () => { expect(await storageService.getActiveProjectId()).toBeNull(); }); + // QNBS-v3: unsupported IndexedDB quarantine must remain a safe no-op rather than inventing a filesystem path. + it('returns null when the IndexedDB backend does not support filesystem quarantine', async () => { + await expect(storageService.quarantineProject('project-1')).resolves.toBeNull(); + }); + it('delegates saveSettings / loadSettings to dbService', async () => { await storageService.saveSettings({} as never); expect(mockDb.saveSettings).toHaveBeenCalled(); From a1cf1beb530612f2fa558823df050ac3c0cc13f4 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:10:11 +0200 Subject: [PATCH 5/5] fix: finalize recovery localization --- README.md | 2 +- locales/de/common.json | 22 +++++++++++----------- locales/es/common.json | 22 +++++++++++----------- locales/fr/common.json | 22 +++++++++++----------- locales/it/common.json | 22 +++++++++++----------- public/locales/de/bundle.json | 22 +++++++++++----------- public/locales/es/bundle.json | 22 +++++++++++----------- public/locales/fr/bundle.json | 22 +++++++++++----------- public/locales/it/bundle.json | 22 +++++++++++----------- 9 files changed, 89 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 268b18518..51988a5e0 100644 --- a/README.md +++ b/README.md @@ -710,7 +710,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt | `deploy` | `main` only | GitHub Pages after **`ci-success`** succeeds | | `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning | -**Current test metrics (2026-08-27, source-synchronized; CI remains authoritative for pass/fail):** +**Current test metrics (2026-08-30, source-synchronized; CI remains authoritative for pass/fail):** - **7317+ unit tests** across **594 test files** — CI is authoritative for pass/fail - Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics) - i18n: **2937 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta) diff --git a/locales/de/common.json b/locales/de/common.json index 70a1c8cd2..3331dbbfa 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -696,16 +696,16 @@ "voice.stopListening": "Zuhören stoppen", "worlds.emptyState.description": "Baue die Orte, Regeln und Geschichten auf, in denen deine Geschichte lebt. Beginne mit einem Ort.", "worlds.emptyState.title": "Die Welt wartet", - "error.startup.description": "The local project or database could not be opened. Reload and try again.", - "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", - "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", - "error.startup.reload": "Reload", - "error.startup.recover": "Quarantine project and reload", - "error.startup.recovering": "Preserving project…", - "error.startup.reset": "Reset database and reload", - "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", - "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.description": "Das lokale Projekt oder die lokale Datenbank konnte nicht geöffnet werden. Laden Sie die Anwendung neu und versuchen Sie es erneut.", + "error.startup.storageUnavailable": "Der lokale Speicher konnte nicht geöffnet werden. Laden Sie die Anwendung neu und versuchen Sie es erneut.", + "error.startup.projectUnavailable": "Ein lokales Projekt konnte nicht geöffnet werden. Laden Sie die Anwendung neu und versuchen Sie es erneut.", + "error.startup.reload": "Neu laden", + "error.startup.recover": "Projekt unter Quarantäne stellen und neu laden", + "error.startup.recovering": "Projekt wird gesichert …", + "error.startup.reset": "Datenbank zurücksetzen und neu laden", + "error.startup.quarantineNotice": "Der vollständige Projektordner wird in die Quarantäne verschoben. Es werden keine Projektdaten gelöscht.", + "error.startup.recoveryFailed": "Die Projektsicherung ist fehlgeschlagen. Das ursprüngliche Projekt wurde nicht gelöscht. Laden Sie die Anwendung neu und versuchen Sie es erneut.", "error.startup.recoveryUnknown": "Die Aufbewahrung des Projekts konnte nicht bestätigt werden. Laden Sie die Anwendung neu und versuchen Sie es erneut.", - "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", - "error.startup.resetWarning": "Resetting the database will delete all local projects and settings." + "error.startup.recoveryAlreadyPreserved": "Das Projekt wurde offenbar bereits durch einen anderen Wiederherstellungsversuch gesichert. Laden Sie die Anwendung neu, um fortzufahren.", + "error.startup.resetWarning": "Das Zurücksetzen der Datenbank löscht alle lokalen Projekte und Einstellungen." } diff --git a/locales/es/common.json b/locales/es/common.json index 203fdd47a..41117663e 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -696,16 +696,16 @@ "voice.stopListening": "Detener escucha", "worlds.emptyState.description": "Construye los lugares, reglas e historias en los que vive tu historia. Comienza con una ubicación.", "worlds.emptyState.title": "El mundo te espera", - "error.startup.description": "The local project or database could not be opened. Reload and try again.", - "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", - "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", - "error.startup.reload": "Reload", - "error.startup.recover": "Quarantine project and reload", - "error.startup.recovering": "Preserving project…", - "error.startup.reset": "Reset database and reload", - "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", - "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.description": "No se pudo abrir el proyecto local o la base de datos. Recarga la aplicación e inténtalo de nuevo.", + "error.startup.storageUnavailable": "No se pudo abrir el almacenamiento local. Recarga la aplicación e inténtalo de nuevo.", + "error.startup.projectUnavailable": "No se pudo abrir un proyecto local. Recarga la aplicación e inténtalo de nuevo.", + "error.startup.reload": "Recargar", + "error.startup.recover": "Poner el proyecto en cuarentena y recargar", + "error.startup.recovering": "Preservando el proyecto…", + "error.startup.reset": "Restablecer la base de datos y recargar", + "error.startup.quarantineNotice": "La carpeta completa del proyecto se moverá a la cuarentena. No se eliminarán datos del proyecto.", + "error.startup.recoveryFailed": "La preservación del proyecto falló. El proyecto original no se eliminó. Recarga la aplicación e inténtalo de nuevo.", "error.startup.recoveryUnknown": "No se pudo confirmar la preservación del proyecto. Recarga la aplicación e inténtalo de nuevo.", - "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", - "error.startup.resetWarning": "Resetting the database will delete all local projects and settings." + "error.startup.recoveryAlreadyPreserved": "Parece que otro intento de recuperación ya ha preservado el proyecto. Recarga la aplicación para continuar.", + "error.startup.resetWarning": "Restablecer la base de datos eliminará todos los proyectos y la configuración locales." } diff --git a/locales/fr/common.json b/locales/fr/common.json index 2db2f27ef..1c0fc7cd9 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -696,16 +696,16 @@ "voice.stopListening": "Arrêter l’écoute", "worlds.emptyState.description": "Construisez les lieux, les règles et les histoires dans lesquels votre récit prend vie. Commencez par un lieu.", "worlds.emptyState.title": "Le monde vous attend", - "error.startup.description": "The local project or database could not be opened. Reload and try again.", - "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", - "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", - "error.startup.reload": "Reload", - "error.startup.recover": "Quarantine project and reload", - "error.startup.recovering": "Preserving project…", - "error.startup.reset": "Reset database and reload", - "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", - "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.description": "Le projet local ou la base de données n’a pas pu être ouvert. Rechargez l’application et réessayez.", + "error.startup.storageUnavailable": "Le stockage local n’a pas pu être ouvert. Rechargez l’application et réessayez.", + "error.startup.projectUnavailable": "Un projet local n’a pas pu être ouvert. Rechargez l’application et réessayez.", + "error.startup.reload": "Recharger", + "error.startup.recover": "Mettre le projet en quarantaine et recharger", + "error.startup.recovering": "Préservation du projet…", + "error.startup.reset": "Réinitialiser la base de données et recharger", + "error.startup.quarantineNotice": "Le dossier complet du projet sera déplacé en quarantaine. Aucune donnée du projet ne sera supprimée.", + "error.startup.recoveryFailed": "La préservation du projet a échoué. Le projet d’origine n’a pas été supprimé. Rechargez l’application et réessayez.", "error.startup.recoveryUnknown": "La préservation du projet n’a pas pu être confirmée. Rechargez l’application et réessayez.", - "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", - "error.startup.resetWarning": "Resetting the database will delete all local projects and settings." + "error.startup.recoveryAlreadyPreserved": "Le projet semble avoir été préservé par une autre tentative de récupération. Rechargez l’application pour continuer.", + "error.startup.resetWarning": "La réinitialisation de la base de données supprimera tous les projets et réglages locaux." } diff --git a/locales/it/common.json b/locales/it/common.json index 0d4e3775c..a4197f98b 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -696,16 +696,16 @@ "voice.stopListening": "Ferma ascolto", "worlds.emptyState.description": "Costruisci i luoghi, le regole e le storie in cui vive la tua narrazione. Inizia con una location.", "worlds.emptyState.title": "Il mondo ti aspetta", - "error.startup.description": "The local project or database could not be opened. Reload and try again.", - "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", - "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", - "error.startup.reload": "Reload", - "error.startup.recover": "Quarantine project and reload", - "error.startup.recovering": "Preserving project…", - "error.startup.reset": "Reset database and reload", - "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", - "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.description": "Non è stato possibile aprire il progetto locale o il database. Ricarica l’applicazione e riprova.", + "error.startup.storageUnavailable": "Non è stato possibile aprire l’archiviazione locale. Ricarica l’applicazione e riprova.", + "error.startup.projectUnavailable": "Non è stato possibile aprire un progetto locale. Ricarica l’applicazione e riprova.", + "error.startup.reload": "Ricarica", + "error.startup.recover": "Metti il progetto in quarantena e ricarica", + "error.startup.recovering": "Conservazione del progetto…", + "error.startup.reset": "Reimposta il database e ricarica", + "error.startup.quarantineNotice": "La cartella completa del progetto verrà spostata in quarantena. Nessun dato del progetto verrà eliminato.", + "error.startup.recoveryFailed": "La conservazione del progetto non è riuscita. Il progetto originale non è stato eliminato. Ricarica l’applicazione e riprova.", "error.startup.recoveryUnknown": "Non è stato possibile confermare la conservazione del progetto. Ricarica l’applicazione e riprova.", - "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", - "error.startup.resetWarning": "Resetting the database will delete all local projects and settings." + "error.startup.recoveryAlreadyPreserved": "Sembra che un altro tentativo di recupero abbia già conservato il progetto. Ricarica per continuare.", + "error.startup.resetWarning": "La reimpostazione del database eliminerà tutti i progetti e le impostazioni locali." } diff --git a/public/locales/de/bundle.json b/public/locales/de/bundle.json index 621fe9ca5..f8c2e723c 100644 --- a/public/locales/de/bundle.json +++ b/public/locales/de/bundle.json @@ -812,18 +812,18 @@ "voice.stopListening": "Zuhören stoppen", "worlds.emptyState.description": "Baue die Orte, Regeln und Geschichten auf, in denen deine Geschichte lebt. Beginne mit einem Ort.", "worlds.emptyState.title": "Die Welt wartet", - "error.startup.description": "The local project or database could not be opened. Reload and try again.", - "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", - "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", - "error.startup.reload": "Reload", - "error.startup.recover": "Quarantine project and reload", - "error.startup.recovering": "Preserving project…", - "error.startup.reset": "Reset database and reload", - "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", - "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.description": "Das lokale Projekt oder die lokale Datenbank konnte nicht geöffnet werden. Laden Sie die Anwendung neu und versuchen Sie es erneut.", + "error.startup.storageUnavailable": "Der lokale Speicher konnte nicht geöffnet werden. Laden Sie die Anwendung neu und versuchen Sie es erneut.", + "error.startup.projectUnavailable": "Ein lokales Projekt konnte nicht geöffnet werden. Laden Sie die Anwendung neu und versuchen Sie es erneut.", + "error.startup.reload": "Neu laden", + "error.startup.recover": "Projekt unter Quarantäne stellen und neu laden", + "error.startup.recovering": "Projekt wird gesichert …", + "error.startup.reset": "Datenbank zurücksetzen und neu laden", + "error.startup.quarantineNotice": "Der vollständige Projektordner wird in die Quarantäne verschoben. Es werden keine Projektdaten gelöscht.", + "error.startup.recoveryFailed": "Die Projektsicherung ist fehlgeschlagen. Das ursprüngliche Projekt wurde nicht gelöscht. Laden Sie die Anwendung neu und versuchen Sie es erneut.", "error.startup.recoveryUnknown": "Die Aufbewahrung des Projekts konnte nicht bestätigt werden. Laden Sie die Anwendung neu und versuchen Sie es erneut.", - "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", - "error.startup.resetWarning": "Resetting the database will delete all local projects and settings.", + "error.startup.recoveryAlreadyPreserved": "Das Projekt wurde offenbar bereits durch einen anderen Wiederherstellungsversuch gesichert. Laden Sie die Anwendung neu, um fortzufahren.", + "error.startup.resetWarning": "Das Zurücksetzen der Datenbank löscht alle lokalen Projekte und Einstellungen.", "copilot.annotationCount": "{{count}} insight for this chapter", "copilot.announceClosed": "KI-Copilot geschlossen", "copilot.announceOpened": "KI-Copilot geöffnet", diff --git a/public/locales/es/bundle.json b/public/locales/es/bundle.json index a3c69c046..d7f3bba7e 100644 --- a/public/locales/es/bundle.json +++ b/public/locales/es/bundle.json @@ -812,18 +812,18 @@ "voice.stopListening": "Detener escucha", "worlds.emptyState.description": "Construye los lugares, reglas e historias en los que vive tu historia. Comienza con una ubicación.", "worlds.emptyState.title": "El mundo te espera", - "error.startup.description": "The local project or database could not be opened. Reload and try again.", - "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", - "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", - "error.startup.reload": "Reload", - "error.startup.recover": "Quarantine project and reload", - "error.startup.recovering": "Preserving project…", - "error.startup.reset": "Reset database and reload", - "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", - "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.description": "No se pudo abrir el proyecto local o la base de datos. Recarga la aplicación e inténtalo de nuevo.", + "error.startup.storageUnavailable": "No se pudo abrir el almacenamiento local. Recarga la aplicación e inténtalo de nuevo.", + "error.startup.projectUnavailable": "No se pudo abrir un proyecto local. Recarga la aplicación e inténtalo de nuevo.", + "error.startup.reload": "Recargar", + "error.startup.recover": "Poner el proyecto en cuarentena y recargar", + "error.startup.recovering": "Preservando el proyecto…", + "error.startup.reset": "Restablecer la base de datos y recargar", + "error.startup.quarantineNotice": "La carpeta completa del proyecto se moverá a la cuarentena. No se eliminarán datos del proyecto.", + "error.startup.recoveryFailed": "La preservación del proyecto falló. El proyecto original no se eliminó. Recarga la aplicación e inténtalo de nuevo.", "error.startup.recoveryUnknown": "No se pudo confirmar la preservación del proyecto. Recarga la aplicación e inténtalo de nuevo.", - "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", - "error.startup.resetWarning": "Resetting the database will delete all local projects and settings.", + "error.startup.recoveryAlreadyPreserved": "Parece que otro intento de recuperación ya ha preservado el proyecto. Recarga la aplicación para continuar.", + "error.startup.resetWarning": "Restablecer la base de datos eliminará todos los proyectos y la configuración locales.", "copilot.annotationCount": "{{count}} insight for this chapter", "copilot.announceClosed": "Copiloto IA cerrado", "copilot.announceOpened": "Copiloto IA abierto", diff --git a/public/locales/fr/bundle.json b/public/locales/fr/bundle.json index 3b791a5dc..d6f017439 100644 --- a/public/locales/fr/bundle.json +++ b/public/locales/fr/bundle.json @@ -812,18 +812,18 @@ "voice.stopListening": "Arrêter l’écoute", "worlds.emptyState.description": "Construisez les lieux, les règles et les histoires dans lesquels votre récit prend vie. Commencez par un lieu.", "worlds.emptyState.title": "Le monde vous attend", - "error.startup.description": "The local project or database could not be opened. Reload and try again.", - "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", - "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", - "error.startup.reload": "Reload", - "error.startup.recover": "Quarantine project and reload", - "error.startup.recovering": "Preserving project…", - "error.startup.reset": "Reset database and reload", - "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", - "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.description": "Le projet local ou la base de données n’a pas pu être ouvert. Rechargez l’application et réessayez.", + "error.startup.storageUnavailable": "Le stockage local n’a pas pu être ouvert. Rechargez l’application et réessayez.", + "error.startup.projectUnavailable": "Un projet local n’a pas pu être ouvert. Rechargez l’application et réessayez.", + "error.startup.reload": "Recharger", + "error.startup.recover": "Mettre le projet en quarantaine et recharger", + "error.startup.recovering": "Préservation du projet…", + "error.startup.reset": "Réinitialiser la base de données et recharger", + "error.startup.quarantineNotice": "Le dossier complet du projet sera déplacé en quarantaine. Aucune donnée du projet ne sera supprimée.", + "error.startup.recoveryFailed": "La préservation du projet a échoué. Le projet d’origine n’a pas été supprimé. Rechargez l’application et réessayez.", "error.startup.recoveryUnknown": "La préservation du projet n’a pas pu être confirmée. Rechargez l’application et réessayez.", - "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", - "error.startup.resetWarning": "Resetting the database will delete all local projects and settings.", + "error.startup.recoveryAlreadyPreserved": "Le projet semble avoir été préservé par une autre tentative de récupération. Rechargez l’application pour continuer.", + "error.startup.resetWarning": "La réinitialisation de la base de données supprimera tous les projets et réglages locaux.", "copilot.annotationCount": "{{count}} insight for this chapter", "copilot.announceClosed": "Copilot IA fermé", "copilot.announceOpened": "Copilot IA ouvert", diff --git a/public/locales/it/bundle.json b/public/locales/it/bundle.json index c50b93d37..ec6d6fb4f 100644 --- a/public/locales/it/bundle.json +++ b/public/locales/it/bundle.json @@ -812,18 +812,18 @@ "voice.stopListening": "Ferma ascolto", "worlds.emptyState.description": "Costruisci i luoghi, le regole e le storie in cui vive la tua narrazione. Inizia con una location.", "worlds.emptyState.title": "Il mondo ti aspetta", - "error.startup.description": "The local project or database could not be opened. Reload and try again.", - "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", - "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", - "error.startup.reload": "Reload", - "error.startup.recover": "Quarantine project and reload", - "error.startup.recovering": "Preserving project…", - "error.startup.reset": "Reset database and reload", - "error.startup.quarantineNotice": "The complete project folder will be moved to quarantine. No project data will be deleted.", - "error.startup.recoveryFailed": "Project preservation failed. The original project was not deleted. Reload and try again.", + "error.startup.description": "Non è stato possibile aprire il progetto locale o il database. Ricarica l’applicazione e riprova.", + "error.startup.storageUnavailable": "Non è stato possibile aprire l’archiviazione locale. Ricarica l’applicazione e riprova.", + "error.startup.projectUnavailable": "Non è stato possibile aprire un progetto locale. Ricarica l’applicazione e riprova.", + "error.startup.reload": "Ricarica", + "error.startup.recover": "Metti il progetto in quarantena e ricarica", + "error.startup.recovering": "Conservazione del progetto…", + "error.startup.reset": "Reimposta il database e ricarica", + "error.startup.quarantineNotice": "La cartella completa del progetto verrà spostata in quarantena. Nessun dato del progetto verrà eliminato.", + "error.startup.recoveryFailed": "La conservazione del progetto non è riuscita. Il progetto originale non è stato eliminato. Ricarica l’applicazione e riprova.", "error.startup.recoveryUnknown": "Non è stato possibile confermare la conservazione del progetto. Ricarica l’applicazione e riprova.", - "error.startup.recoveryAlreadyPreserved": "The project appears to have been preserved by another recovery attempt. Reload to continue.", - "error.startup.resetWarning": "Resetting the database will delete all local projects and settings.", + "error.startup.recoveryAlreadyPreserved": "Sembra che un altro tentativo di recupero abbia già conservato il progetto. Ricarica per continuare.", + "error.startup.resetWarning": "La reimpostazione del database eliminerà tutti i progetti e le impostazioni locali.", "copilot.annotationCount": "{{count}} insight for this chapter", "copilot.announceClosed": "Copilota IA chiuso", "copilot.announceOpened": "Copilota IA aperto",