Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<img src="https://img.shields.io/badge/Storage-IndexedDB_v8-F59E0B" alt="IndexedDB v8">
<img src="https://img.shields.io/badge/PWA-v3.0-5BB974?logo=pwa" alt="PWA v3.0">
<img src="https://img.shields.io/badge/i18n-19_locales-2925_keys-0EA5E9" alt="i18n 19 locales — 2925 keys">
<img src="https://img.shields.io/badge/Tests-7266%2B_%2F_591_files-22C55E" alt="7266+ tests / 591 files">
<img src="https://img.shields.io/badge/Tests-7268%2B_%2F_591_files-22C55E" alt="7268+ tests / 591 files">
<img src="https://img.shields.io/codecov/c/github/qnbs/WorldScript-Studio?logo=codecov&label=Coverage" alt="Codecov Coverage">
<img src="https://img.shields.io/badge/License-MIT-22C55E" alt="License MIT">
<img src="https://img.shields.io/github/actions/workflow/status/qnbs/WorldScript-Studio/.github/workflows/ci.yml?branch=main&logo=github" alt="CI Status">
Expand Down Expand Up @@ -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` |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
127 changes: 101 additions & 26 deletions index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void>;
}) {
const [recoveryError, setRecoveryError] = React.useState<string | null>(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));
Comment thread
qnbs marked this conversation as resolved.
Outdated
setIsRecovering(false);
}
};

return (
<div
style={{
Expand All @@ -123,8 +149,8 @@ function StorageErrorScreen({ message, onReset }: { message: string; onReset: ()
>
<h1 style={{ fontSize: '1.5rem', fontWeight: 700 }}>WorldScript Studio</h1>
<p style={{ color: '#94a3b8', maxWidth: '32rem' }}>
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.
</p>
<p
style={{
Expand Down Expand Up @@ -155,25 +181,58 @@ function StorageErrorScreen({ message, onReset }: { message: string; onReset: ()
>
Reload
</button>
<button
type="button"
onClick={onReset}
style={{
padding: '0.5rem 1.25rem',
borderRadius: '0.5rem',
border: 'none',
background: '#dc2626',
color: '#fff',
cursor: 'pointer',
fontFamily: 'inherit',
}}
>
Reset Database &amp; Reload
</button>
{onRecover && (
<button
type="button"
onClick={() => void handleRecover()}
disabled={isRecovering}
style={{
padding: '0.5rem 1.25rem',
borderRadius: '0.5rem',
border: 'none',
background: '#2563eb',
color: '#fff',
cursor: isRecovering ? 'wait' : 'pointer',
fontFamily: 'inherit',
}}
>
{isRecovering ? 'Preserving Project…' : 'Quarantine Corrupt Project & Reload'}
</button>
)}
{onReset && (
<button
type="button"
onClick={onReset}
style={{
padding: '0.5rem 1.25rem',
borderRadius: '0.5rem',
border: 'none',
background: '#dc2626',
color: '#fff',
cursor: 'pointer',
fontFamily: 'inherit',
}}
>
Reset Database &amp; Reload
</button>
)}
</div>
<p style={{ fontSize: '0.75rem', color: '#475569' }}>
Warning: resetting the database will delete all local projects and settings.
</p>
{onRecover && (
<p style={{ fontSize: '0.75rem', color: '#94a3b8', maxWidth: '32rem' }}>
The complete project folder will be moved to the app&apos;s quarantined-projects folder;
no project data will be deleted.
</p>
)}
{recoveryError && (
<p style={{ fontSize: '0.875rem', color: '#fca5a5', maxWidth: '32rem' }}>
Project preservation failed. The original project was not deleted. {recoveryError}
</p>
)}
{onReset && (
<p style={{ fontSize: '0.75rem', color: '#475569' }}>
Warning: resetting the database will delete all local projects and settings.
</p>
)}
</div>
);
}
Expand Down Expand Up @@ -287,14 +346,30 @@ async function bootApp(): Promise<void> {
}
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(
<React.StrictMode>
<StorageErrorScreen
message={msg}
onReset={async () => {
await resetAllDatabases();
window.location.reload();
}}
{...(canQuarantine
? {
onRecover: async () => {
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();
},
}
: {})}
Comment thread
qnbs marked this conversation as resolved.
Outdated
/>
</React.StrictMode>,
);
Expand Down
33 changes: 32 additions & 1 deletion services/fs/projectFsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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,
);
}

Expand All @@ -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,
);
}

Expand All @@ -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<ProjectQuarantineResult> {
Comment thread
qnbs marked this conversation as resolved.
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.`);
Comment thread
qnbs marked this conversation as resolved.
Outdated
}

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));
Comment thread
qnbs marked this conversation as resolved.
Outdated
return { projectId, path: quarantinePath };
}

throw new Error(`Could not quarantine project "${projectId}": recovery target already exists.`);
}

async deleteProject(projectId: string): Promise<void> {
const apis = await this.getApis();
const appDataPath = await this.ensureAppDataPath();
Expand Down
8 changes: 8 additions & 0 deletions services/storageBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -66,6 +72,8 @@ export interface StorageBackend {
deleteProject(projectId: string): Promise<void>;
/** 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<string | null>;
/** Desktop-only preserve-first recovery; IndexedDB has no filesystem project to quarantine. */
quarantineProject?(projectId: string): Promise<ProjectQuarantineResult>;

saveImage(id: string, base64Data: string): Promise<void>;
getImage(id: string): Promise<string | null>;
Expand Down
7 changes: 7 additions & 0 deletions services/storageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import type { ProjectSnapshot, Settings, StoryCodex, StoryProject } from '../typ
import type {
BinderAssetMeta,
BinderAssetPayload,
ProjectQuarantineResult,
SaveProjectInput,
StorageBackend,
} from './storageBackend';

export type {
BinderAssetMeta,
BinderAssetPayload,
ProjectQuarantineResult,
SaveProjectEnvelope,
SaveProjectInput,
StorageBackend,
Expand Down Expand Up @@ -89,6 +91,11 @@ class StorageManager {
return (await backend.getActiveProjectId?.()) ?? null;
}

async quarantineProject(projectId: string): Promise<ProjectQuarantineResult | null> {
const backend = await this.getBackend();
return (await backend.quarantineProject?.(projectId)) ?? null;
}

async deleteProject(projectId: string): Promise<void> {
const backend = await this.getBackend();
return backend.deleteProject(projectId);
Expand Down
6 changes: 5 additions & 1 deletion tests/unit/libraryBackupService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Expand Down
Loading
Loading