Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
8cfbac7
fix(e2e): eliminate WelcomePortal startup/navigation nondeterminism (…
qnbs Sep 2, 2026
9238faa
fix(e2e): close review findings on #532 fix — reject-on-blocked, TOCT…
qnbs Sep 2, 2026
1bb0627
fix(storage): close every long-lived IDB connection during factory re…
qnbs Sep 2, 2026
f4a235a
docs: sync README test/i18n-key metrics (7370 tests/596 files, 2938 k…
qnbs Sep 2, 2026
0fc0a57
chore(i18n): rebuild locale bundles for the new factoryReset.failed key
qnbs Sep 2, 2026
f911f43
refactor(settings): extract factory-reset failure handling out of use…
qnbs Sep 2, 2026
ed53cc9
fix(settings): move the entire factory-reset try/catch out of useSett…
qnbs Sep 2, 2026
d693e25
fix(storage): async epoch-based reset quiescence contract across ever…
qnbs Sep 2, 2026
6b4c950
fix(graphs): fail-closed reset gate, late-registration barrier, singl…
qnbs Sep 2, 2026
74e32f7
fix(graphs): close the reset-generation gap for opens that start mid-…
qnbs Sep 2, 2026
6132fb1
fix(graphs): preserve-first deletion ownership, memoization root caus…
qnbs Sep 2, 2026
dda1c0d
fix(graphs): stale README metrics date and weak reset-closer test ass…
qnbs Sep 2, 2026
806317f
fix(graphs): reset closer must invalidate the pending flight, not jus…
qnbs Sep 2, 2026
c4553ac
fix(storage): close three reset-quiescence gaps found by #596's own r…
qnbs Sep 2, 2026
f13e60f
fix(storage): close a second wave of review findings on the reset-qui…
qnbs Sep 3, 2026
9e9c406
test(storage): close the codecov/patch coverage gap on the reset-quie…
qnbs Sep 3, 2026
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
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
<img src="https://img.shields.io/badge/Release-v1.28.3-6366F1" alt="Release v1.28.3">
<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-2937_keys-0EA5E9" alt="i18n 19 locales — 2937 keys">
<img src="https://img.shields.io/badge/Tests-7370%2B_%2F_595_files-22C55E" alt="7370+ tests / 595 files">
<img src="https://img.shields.io/badge/i18n-19_locales-2938_keys-0EA5E9" alt="i18n 19 locales — 2938 keys">
<img src="https://img.shields.io/badge/Tests-7424%2B_%2F_597_files-22C55E" alt="7424+ tests / 597 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 @@ -401,7 +401,7 @@ Infrastructure-level features that keep the app fast and extensible as projects

### 🌐 Full Multi-Language Support

Shipped UI locales with **2937 i18n keys** across all 19 languages — zero hardcoded user-facing strings:
Shipped UI locales with **2938 i18n keys** across all 19 languages — zero hardcoded user-facing strings:
Comment thread
qnbs marked this conversation as resolved.

- 🇩🇪 **German** (Deutsch)
- 🇬🇧 **English**
Expand Down Expand Up @@ -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`) | 2937 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 (7370+ tests / 595 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2938 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 (7424+ tests / 597 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 (7370+ tests, 595 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ ├── unit/ # Vitest unit tests (7424+ tests, 597 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,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-30, source-synchronized; CI remains authoritative for pass/fail):**
- **7370+ unit tests** across **595 test files** — CI is authoritative for pass/fail
- **7424+ unit tests** across **597 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)
- i18n: **2938 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.

Expand Down
55 changes: 39 additions & 16 deletions app/listenerMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -729,26 +729,43 @@ function withLocalFirstLock<T>(fn: () => Promise<T>): Promise<T> {
return run;
}

// QNBS-v3: extracted so getLocalFirstHandle's own body doesn't absorb this classification/teardown complexity (CodeScene hotspot). Mutates the module-level localFirstHandle directly; returns the still-valid handle to reuse, or null once any stale/mismatched handle has been torn down and cleared.
async function reconcileLocalFirstHandle(
projectId: string,
isIdbEncryptionReady: () => boolean,
noopPersistence: LocalFirstHandle['persistence'],
): Promise<LocalFirstHandle | null> {
if (!localFirstHandle) return null;
if (localFirstHandle.projectId !== projectId) {
// Project switched — tear down the previous handle before creating a new one.
await localFirstHandle.persistence.destroy().catch(() => undefined);
localFirstHandle = null;
return null;
}
// QNBS-v3 (CodeAnt): the persistence backend is chosen at handle creation — if at-rest encryption became active after a plaintext-persisting handle was made, tear it down (wiping the plaintext already written) so no further plaintext is persisted.
if (isIdbEncryptionReady() && localFirstHandle.persistence.active) {
await localFirstHandle.persistence.clearData().catch(() => undefined);
await localFirstHandle.persistence.destroy().catch(() => undefined);
Comment thread
qnbs marked this conversation as resolved.
localFirstHandle = null;
return null;
}
// QNBS-v3: a dead reference, not an intentional NOOP — recreate rather than return a handle writes would silently go nowhere through.
if (localFirstHandle.persistence !== noopPersistence && !localFirstHandle.persistence.active) {
localFirstHandle = null;
return null;
}
// QNBS-v3 (CodeAnt): mirror image of the case above — if encryption was ready when this handle chose NOOP and has since been disabled, discard it too, or local-first sync stays memory-only forever after a disable.
if (!isIdbEncryptionReady() && localFirstHandle.persistence === noopPersistence) {
localFirstHandle = null;
return null;
}
return localFirstHandle;
Comment thread
qnbs marked this conversation as resolved.
}

function getLocalFirstHandle(project: ProjectData): Promise<LocalFirstHandle> {
return withLocalFirstLock(async () => {
const projectId = project.id ?? 'default';
const { isIdbEncryptionReady } = await import('../services/storage/storageEncryptionService');
if (localFirstHandle?.projectId === projectId) {
// QNBS-v3 (CodeAnt): the persistence backend (NOOP vs y-indexeddb) is chosen at handle
// creation. If at-rest encryption became active AFTER a plaintext-persisting handle was made,
// tear it down — wiping the plaintext already written — so no further plaintext is persisted.
if (isIdbEncryptionReady() && localFirstHandle.persistence.active) {
await localFirstHandle.persistence.clearData().catch(() => undefined);
await localFirstHandle.persistence.destroy().catch(() => undefined);
localFirstHandle = null;
} else {
return localFirstHandle;
}
} else if (localFirstHandle) {
// Project switched — tear down the previous handle before creating a new one.
await localFirstHandle.persistence.destroy().catch(() => undefined);
localFirstHandle = null;
}
const [
{ createBlankProjectDoc },
{ ProjectDocBinding },
Expand All @@ -758,6 +775,12 @@ function getLocalFirstHandle(project: ProjectData): Promise<LocalFirstHandle> {
import('../services/localFirst/docBinding'),
import('../services/localFirst/docPersistence'),
]);
const reused = await reconcileLocalFirstHandle(
projectId,
isIdbEncryptionReady,
NOOP_PERSISTENCE,
);
if (reused) return reused;
const doc = createBlankProjectDoc();
// QNBS-v3 (CodeAnt): never write a PLAINTEXT shadow copy to y-indexeddb when at-rest encryption
// is active — the local-first doc is not encrypted yet. Keep it in-memory only so the privacy
Expand Down
3 changes: 2 additions & 1 deletion hooks/useFactoryReset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ export function useFactoryReset({ t, setBusy, setError }: Options): () => Promis
try {
await wipeAllAppData();
} catch (err) {
setError(t('settings.privacy.encryptionRecoveryFailed'));
// QNBS-v3: a failed factory reset can leave partial cleanup behind — never reuse encryptionRecoveryFailed's "your data has not been lost" claim here.
setError(t('settings.data.dangerZone.factoryReset.failed'));
logger.error('Factory reset failed', {
error: err instanceof Error ? err.message : String(err),
});
Expand Down
20 changes: 17 additions & 3 deletions hooks/useSettingsView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,21 @@ import type {
type ModalState = 'closed' | 'reset' | 'restore' | 'delete' | 'create' | 'factoryReset';
type ModalPayload = { id?: number; name?: string; date?: string; wordCount?: number };

// QNBS-v3: the whole try/catch lives here, not in useSettingsView's own body, so this branch's complexity is never attributed to that already-flagged hotspot (CodeScene).
async function performFactoryReset(
t: (key: string) => string,
toast: ReturnType<typeof useToast>,
): Promise<void> {
try {
await wipeAllAppData();
} catch (error) {
logger.error('Factory reset failed', {
error: error instanceof Error ? error.message : String(error),
});
toast.error(t('settings.data.dangerZone.factoryReset.failed'));
}
}

export const useSettingsView = () => {
const { t, language, setLanguage } = useTranslation();
const dispatch = useAppDispatch();
Expand Down Expand Up @@ -349,9 +364,8 @@ export const useSettingsView = () => {

const handleFactoryReset = useCallback(async () => {
setModal({ state: 'closed', payload: {} });
// QNBS-v3: wipes all IDB databases, localStorage, SW caches, then reloads.
await wipeAllAppData();
}, []);
await performFactoryReset(t, toast);
}, [t, toast]);

const handleRepeatOnboarding = useCallback(() => {
// QNBS-v3: useApp.ts listens for this event and re-opens the WelcomePortal.
Expand Down
1 change: 1 addition & 0 deletions locales/ar/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@
"settings.data.createSnapshot": "إنشاء لقطة",
"settings.data.dangerZone.description": "هذه الإجراءات لا رجعة فيها. تابع بحذر.",
"settings.data.dangerZone.factoryReset.button": "إعادة ضبط المصنع",
"settings.data.dangerZone.factoryReset.failed": "لم تكتمل إعادة ضبط المصنع — قد يكون التطبيق الآن في حالة إعادة ضبط جزئية. أعد تشغيل التطبيق للتحقق، ثم أعد محاولة إعادة الضبط.",
"settings.data.dangerZone.factoryReset.hint": "يحذف نهائيًا جميع المشاريع والإعدادات ومفاتيح API والبيانات المحلية. سيُعاد تشغيل التطبيق كتثبيت جديد.",
"settings.data.dangerZone.factoryReset.label": "إعادة ضبط جميع بيانات التطبيق",
"settings.data.dangerZone.factoryReset.modalConfirm": "حذف كل شيء وإعادة التشغيل",
Expand Down
2 changes: 1 addition & 1 deletion locales/ar/sidebar.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@
"sidebar.world": "بناء العالم",
"sidebar.writer": "استوديو الكتابة بالذكاء الاصطناعي",
"sidebar.scenario": "السيناريو / السيناريو السينمائي"
}
}
1 change: 1 addition & 0 deletions locales/de/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@
"settings.data.createSnapshot": "Schnappschuss erstellen",
"settings.data.dangerZone.description": "Diese Aktionen sind unwiderruflich. Vorsicht!",
"settings.data.dangerZone.factoryReset.button": "Werkseinstellungen",
"settings.data.dangerZone.factoryReset.failed": "Der Werksreset wurde nicht abgeschlossen – die App befindet sich möglicherweise in einem teilweise zurückgesetzten Zustand. Starten Sie die App neu, um dies zu überprüfen, und versuchen Sie den Reset erneut.",
"settings.data.dangerZone.factoryReset.hint": "Löscht alle Projekte, Einstellungen, API-Schlüssel und lokalen Daten dauerhaft. Die App startet neu wie bei einer Erstinstallation.",
"settings.data.dangerZone.factoryReset.label": "Alle App-Daten zurücksetzen",
"settings.data.dangerZone.factoryReset.modalConfirm": "Alles löschen & neu starten",
Expand Down
2 changes: 1 addition & 1 deletion locales/de/sidebar.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@
"sidebar.world": "Weltenbau",
"sidebar.writer": "KI-Schreibstudio",
"sidebar.scenario": "Szenario / Drehbuch"
}
}
1 change: 1 addition & 0 deletions locales/el/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@
"settings.data.createSnapshot": "Δημιουργία Snapshot",
"settings.data.dangerZone.description": "Αυτές οι ενέργειες είναι μη αναστρέψιμες. Προχωρήστε με προσοχή.",
"settings.data.dangerZone.factoryReset.button": "Επαναφορά",
"settings.data.dangerZone.factoryReset.failed": "Η επαναφορά εργοστασιακών ρυθμίσεων δεν ολοκληρώθηκε — η εφαρμογή ενδέχεται να βρίσκεται τώρα σε κατάσταση μερικής επαναφοράς. Επανεκκινήστε την εφαρμογή για έλεγχο και δοκιμάστε ξανά την επαναφορά.",
"settings.data.dangerZone.factoryReset.hint": "Διαγράφει οριστικά όλα τα έργα, τις ρυθμίσεις, τα κλειδιά API και τα τοπικά δεδομένα. Η εφαρμογή θα επανεκκινηθεί ως νέα εγκατάσταση.",
"settings.data.dangerZone.factoryReset.label": "Επαναφορά όλων των δεδομένων εφαρμογής",
"settings.data.dangerZone.factoryReset.modalConfirm": "Διαγραφή everything & restart",
Expand Down
1 change: 1 addition & 0 deletions locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@
"settings.data.createSnapshot": "Create Snapshot",
"settings.data.dangerZone.description": "These actions are irreversible. Proceed with caution.",
"settings.data.dangerZone.factoryReset.button": "Factory Reset",
"settings.data.dangerZone.factoryReset.failed": "Factory reset did not complete — the app may now be in a partially reset state. Restart the app to check, then try the reset again.",
"settings.data.dangerZone.factoryReset.hint": "Permanently deletes all projects, settings, API keys, and local data. The app will restart as a fresh install.",
"settings.data.dangerZone.factoryReset.label": "Reset all app data",
"settings.data.dangerZone.factoryReset.modalConfirm": "Delete everything & restart",
Expand Down
1 change: 1 addition & 0 deletions locales/es/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@
"settings.data.createSnapshot": "Crear instantánea",
"settings.data.dangerZone.description": "Estas acciones son irreversibles. Procede con precaución.",
"settings.data.dangerZone.factoryReset.button": "Restablecimiento de fábrica",
"settings.data.dangerZone.factoryReset.failed": "El restablecimiento de fábrica no se completó — la aplicación puede estar en un estado parcialmente restablecido. Reinicia la aplicación para comprobarlo y vuelve a intentar el restablecimiento.",
"settings.data.dangerZone.factoryReset.hint": "Elimina permanentemente todos los proyectos, configuraciones, claves API y datos locales. La app se reinicia como instalación nueva.",
"settings.data.dangerZone.factoryReset.label": "Restablecer todos los datos",
"settings.data.dangerZone.factoryReset.modalConfirm": "Eliminar todo y reiniciar",
Expand Down
2 changes: 1 addition & 1 deletion locales/es/sidebar.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@
"sidebar.world": "Mundo",
"sidebar.writer": "Estudio de escritura IA",
"sidebar.scenario": "Escenario / Guion"
}
}
1 change: 1 addition & 0 deletions locales/eu/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@
"settings.data.createSnapshot": "Sortu argazkia",
"settings.data.dangerZone.description": "Ekintza hauek atzeraezinak dira. Kontuz ibili.",
"settings.data.dangerZone.factoryReset.button": "Fabrika berrezarri",
"settings.data.dangerZone.factoryReset.failed": "Fabrikako berrezarpena ez da amaitu — aplikazioa erdi berrezarritako egoeran egon daiteke orain. Berrabiarazi aplikazioa egiaztatzeko, eta saiatu berrezarpena berriro.",
"settings.data.dangerZone.factoryReset.hint": "Proiektu, ezarpen, API gako eta tokiko datu guztiak behin betiko ezabatzen ditu. Aplikazioa instalazio berri gisa berrabiaraziko da.",
"settings.data.dangerZone.factoryReset.label": "Berrezarri aplikazioaren datu guztiak",
"settings.data.dangerZone.factoryReset.modalConfirm": "Ezabatu dena eta berrabiarazi",
Expand Down
2 changes: 1 addition & 1 deletion locales/eu/sidebar.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@
"sidebar.world": "Mundu-eraikuntza",
"sidebar.writer": "AI idazketa-estudioa",
"sidebar.scenario": "Eszenatokia / Gidoia"
}
}
1 change: 1 addition & 0 deletions locales/fa/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@
"settings.data.createSnapshot": "ایجاد عکس فوری",
"settings.data.dangerZone.description": "این اقدامات برگشت ناپذیر است. با احتیاط ادامه دهید",
"settings.data.dangerZone.factoryReset.button": "تنظیم مجدد کارخانه",
"settings.data.dangerZone.factoryReset.failed": "تنظیم مجدد کارخانه کامل نشد — ممکن است برنامه اکنون در وضعیت بازنشانی جزئی باشد. برنامه را دوباره راه‌اندازی کنید تا بررسی شود، سپس بازنشانی را دوباره امتحان کنید.",
"settings.data.dangerZone.factoryReset.hint": "تمام پروژه ها، تنظیمات، کلیدهای API و داده های محلی را برای همیشه حذف می کند. برنامه به عنوان یک نصب تازه راه اندازی مجدد می شود.",
"settings.data.dangerZone.factoryReset.label": "تمام داده های برنامه را بازنشانی کنید",
"settings.data.dangerZone.factoryReset.modalConfirm": "همه چیز را پاک کنید و دوباره راه اندازی کنید",
Expand Down
2 changes: 1 addition & 1 deletion locales/fa/sidebar.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@
"sidebar.world": "جهان‌سازی",
"sidebar.writer": "استودیوی نویسندگی هوش مصنوعی",
"sidebar.scenario": "سناریو / فیلمنامه"
}
}
1 change: 1 addition & 0 deletions locales/fi/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@
"settings.data.createSnapshot": "Luo tilannekuva",
"settings.data.dangerZone.description": "Nämä toimet ovat peruuttamattomia. Jatka varovasti.",
"settings.data.dangerZone.factoryReset.button": "Tehdasasetusten palautus",
"settings.data.dangerZone.factoryReset.failed": "Tehdasasetusten palautus ei valmistunut — sovellus saattaa nyt olla osittain palautetussa tilassa. Käynnistä sovellus uudelleen tarkistaaksesi tilanteen ja yritä palautusta sitten uudelleen.",
"settings.data.dangerZone.factoryReset.hint": "Poistaa pysyvästi kaikki projektit, asetukset, API-avaimet ja paikalliset tiedot. Sovellus käynnistyy uudelleen uutena asennuksena.",
"settings.data.dangerZone.factoryReset.label": "Nollaa kaikki sovellustiedot",
"settings.data.dangerZone.factoryReset.modalConfirm": "Poista kaikki ja käynnistä uudelleen",
Expand Down
2 changes: 1 addition & 1 deletion locales/fi/sidebar.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@
"sidebar.world": "Maailmanrakennus",
"sidebar.writer": "AI-kirjoitusstudio",
"sidebar.scenario": "Skenaario / Käsikirjoitus"
}
}
Loading
Loading