From 2d3ea33ce620c821a463122926a595bb887fa29d Mon Sep 17 00:00:00 2001
From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com>
Date: Thu, 9 Apr 2026 19:25:08 -0700
Subject: [PATCH] refactor: migrate project storage from IndexedDB to OPFS with
folder-based structure
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Replace monolithic KGStorage with KGProjectStorage (OPFS) and KGConfigStorage (IndexedDB)
- Each project stored as folder: project.json + meta.json + media/
- Add KGConfigUpgrader with V1 upgrader to auto-migrate existing IndexedDB projects to OPFS
- Request persistent storage via navigator.storage.persist() to prevent browser eviction
- Show loading spinner during one-time migration
- Enforce safe project name characters (letters, numbers, space, hyphen, underscore, period, parens)
- Export projects as .kgstudio zip bundles instead of raw JSON
- Import supports .kgstudio bundles (with meta.json validation) and legacy JSON files
- Auto-deduplicate project names on import with (1), (2), etc. suffix
- Add OPFS shell debugger (pwd, ls, cd, cat, dl) accessible via KGDebugger.opfs()
- Add ESLint semi rule for consistent semicolons
- Delete KGStorage.ts — all logic absorbed by new storage classes
- Add jszip dependency for zip export/import
---
eslint.config.js | 3 +-
package-lock.json | 52 ++-
package.json | 1 +
src/App.tsx | 22 +
src/components/Toolbar.tsx | 150 ++++---
src/constants/coreConstants.ts | 12 +
src/core/KGCore.ts | 38 +-
src/core/KGDebugger.ts | 216 +++++++++-
src/core/config-upgrader/KGConfigUpgrader.ts | 62 +++
.../config-upgrader/upgradeConfigToV1.test.ts | 180 ++++++++
src/core/config-upgrader/upgradeConfigToV1.ts | 144 +++++++
src/core/config/ConfigManager.ts | 13 +-
src/core/io/KGConfigStorage.test.ts | 86 ++++
src/core/io/KGConfigStorage.ts | 119 ++++++
src/core/io/KGProjectStorage.test.ts | 230 ++++++++++
src/core/io/KGProjectStorage.ts | 398 ++++++++++++++++++
src/core/io/KGStorage.ts | 132 ------
src/types/opfs.d.ts | 9 +
src/util/projectNameUtil.test.ts | 78 ++++
src/util/projectNameUtil.ts | 44 ++
src/util/saveUtil.ts | 27 +-
21 files changed, 1781 insertions(+), 235 deletions(-)
create mode 100644 src/core/config-upgrader/KGConfigUpgrader.ts
create mode 100644 src/core/config-upgrader/upgradeConfigToV1.test.ts
create mode 100644 src/core/config-upgrader/upgradeConfigToV1.ts
create mode 100644 src/core/io/KGConfigStorage.test.ts
create mode 100644 src/core/io/KGConfigStorage.ts
create mode 100644 src/core/io/KGProjectStorage.test.ts
create mode 100644 src/core/io/KGProjectStorage.ts
delete mode 100644 src/core/io/KGStorage.ts
create mode 100644 src/types/opfs.d.ts
create mode 100644 src/util/projectNameUtil.test.ts
create mode 100644 src/util/projectNameUtil.ts
diff --git a/eslint.config.js b/eslint.config.js
index fd7545f..8e7e63c 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -21,7 +21,8 @@ export default tseslint.config([
},
rules: {
'@typescript-eslint/no-unused-vars': 'warn', // Downgrade from error to warning
- 'no-unused-vars': 'warn' // Also set the base rule to warn
+ 'no-unused-vars': 'warn', // Also set the base rule to warn
+ 'semi': ['error', 'always'],
}
},
])
diff --git a/package-lock.json b/package-lock.json
index 76bc26e..9ebe900 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,15 +1,16 @@
{
"name": "K.G.Studio",
- "version": "0.8.0-build.20260123",
+ "version": "0.9.0-build.20260406",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "K.G.Studio",
- "version": "0.8.0-build.20260123",
+ "version": "0.9.0-build.20260406",
"dependencies": {
"class-transformer": "^0.5.1",
"idb": "^8.0.3",
+ "jszip": "^3.10.1",
"openai": "^6.33.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
@@ -3805,7 +3806,6 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/cosmiconfig": {
@@ -5387,6 +5387,12 @@
"node": ">= 4"
}
},
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+ "license": "MIT"
+ },
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -5441,7 +5447,6 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
- "dev": true,
"license": "ISC"
},
"node_modules/ini": {
@@ -5656,7 +5661,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/isexe": {
@@ -5931,6 +5935,18 @@
"node": "*"
}
},
+ "node_modules/jszip": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
+ "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+ "license": "(MIT OR GPL-3.0-or-later)",
+ "dependencies": {
+ "lie": "~3.3.0",
+ "pako": "~1.0.2",
+ "readable-stream": "~2.3.6",
+ "setimmediate": "^1.0.5"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -5965,6 +5981,15 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -11030,6 +11055,12 @@
"dev": true,
"license": "BlueOak-1.0.0"
},
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -11377,7 +11408,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
- "dev": true,
"license": "MIT"
},
"node_modules/property-information": {
@@ -11792,7 +11822,6 @@
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
@@ -12156,7 +12185,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
- "dev": true,
"license": "MIT"
},
"node_modules/safer-buffer": {
@@ -12529,6 +12557,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+ "license": "MIT"
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -12836,7 +12870,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
@@ -13674,7 +13707,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
- "dev": true,
"license": "MIT"
},
"node_modules/validate-npm-package-license": {
diff --git a/package.json b/package.json
index 50f19a3..50c926f 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"dependencies": {
"class-transformer": "^0.5.1",
"idb": "^8.0.3",
+ "jszip": "^3.10.1",
"openai": "^6.33.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
diff --git a/src/App.tsx b/src/App.tsx
index fc635c3..4f7fae2 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -141,6 +141,9 @@ function App() {
{/* Global Loading Overlay for instrument buffer loading */}
+
+ {/* Migration Loading Overlay */}
+
);
}
@@ -210,3 +213,22 @@ const GlobalLoadingOverlayContainer: React.FC = () => {
/>
);
};
+
+// Migration overlay — shown during one-time IndexedDB -> OPFS migration
+const MigrationOverlayContainer: React.FC = () => {
+ const [isMigrating, setIsMigrating] = useState(() => KGCore.instance().getIsMigrating());
+
+ useEffectReact(() => {
+ KGCore.instance().setMigrationStateChangeCallback(setIsMigrating);
+ return () => {
+ KGCore.instance().setMigrationStateChangeCallback(() => {});
+ };
+ }, []);
+
+ return (
+
+ );
+};
diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx
index 01d81b9..b9b155f 100644
--- a/src/components/Toolbar.tsx
+++ b/src/components/Toolbar.tsx
@@ -1,8 +1,8 @@
import React from 'react';
import './Toolbar.css';
import { saveProject } from '../util/saveUtil';
-import { KGStorage } from '../core/io/KGStorage';
-import { DB_CONSTANTS } from '../constants/coreConstants';
+import { KGProjectStorage } from '../core/io/KGProjectStorage';
+import { isValidProjectName } from '../util/projectNameUtil';
import { KGCore } from '../core/KGCore';
import { useProjectStore } from '../stores/projectStore';
import { DEBUG_MODE } from '../constants/uiConstants';
@@ -15,7 +15,7 @@ import {
FaCog
} from 'react-icons/fa';
import { KGProject, type KeySignature } from '../core/KGProject';
-import { plainToInstance, instanceToPlain } from 'class-transformer';
+import { plainToInstance } from 'class-transformer';
import { FaPencil, FaCopy, FaPaste, FaTrash } from 'react-icons/fa6';
import { KGMainContentState } from '../core/state/KGMainContentState';
import { regionDeleteManager } from '../util/regionDeleteUtil';
@@ -59,11 +59,17 @@ const Toolbar: React.FC = () => {
const keySignatureOptions = Object.keys(KEY_SIGNATURE_MAP) as KeySignature[];
// Export options
- const exportOptions = ["Export to KGStudio JSON file", "Export to MIDI file"];
+ const exportOptions = ["Export to KGStudio file", "Export to MIDI file"];
const handleProjectNameClick = () => {
const newName = prompt("Enter project name:", projectName);
- if (newName) setProjectName(newName);
+ if (newName) {
+ if (!isValidProjectName(newName)) {
+ window.alert("Invalid project name. Only letters, numbers, spaces, hyphens, underscores, periods, and parentheses are allowed.");
+ return;
+ }
+ setProjectName(newName);
+ }
};
// Common project loading logic extracted for reuse
@@ -145,14 +151,8 @@ const Toolbar: React.FC = () => {
try {
// Try to load the project from storage
- const storage = KGStorage.getInstance();
- const loadedProject = await storage.load(
- DB_CONSTANTS.DB_NAME,
- DB_CONSTANTS.PROJECTS_STORE_NAME,
- projectNameToLoad.trim(),
- KGProject,
- DB_CONSTANTS.DB_VERSION
- );
+ const storage = KGProjectStorage.getInstance();
+ const loadedProject = await storage.load(projectNameToLoad.trim());
if (!loadedProject) {
window.alert(`Project "${projectNameToLoad}" not found. Please check the project name and try again.`);
@@ -182,8 +182,8 @@ const Toolbar: React.FC = () => {
console.log("user selected export option:", exportType);
}
- if (exportType === "Export to KGStudio JSON file") {
- handleExportKGStudioJSON();
+ if (exportType === "Export to KGStudio file") {
+ handleExportKGStudio();
} else if (exportType === "Export to MIDI file") {
handleExportMIDI();
}
@@ -191,46 +191,39 @@ const Toolbar: React.FC = () => {
setShowExportDropdown(false);
};
- const handleExportKGStudioJSON = () => {
+ const handleExportKGStudio = async () => {
if (DEBUG_MODE.TOOLBAR) {
- console.log("exporting to KGStudio JSON file");
+ console.log("exporting to KGStudio file");
}
-
+
try {
- // Get the current project from KGCore
- const currentProject = KGCore.instance().getCurrentProject();
-
- // Serialize the project to JSON (same format as saved to IndexedDB)
- // Use instanceToPlain to include type information for class-transformer
- const projectData = JSON.stringify(instanceToPlain(currentProject), null, 2);
-
- // Create a downloadable blob
- const blob = new Blob([projectData], { type: 'application/json' });
-
- // Create a temporary download link
+ // First save the current project to OPFS so the export reflects the latest state
+ const storage = KGProjectStorage.getInstance();
+ await storage.save(projectName, KGCore.instance().getCurrentProject(), true);
+
+ // Bundle the project folder into a zip
+ const blob = await storage.exportAsZip(projectName);
+
+ // Trigger download
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
- link.download = `${projectName}.json`;
-
- // Trigger download
+ link.download = `${projectName}.kgstudio`;
document.body.appendChild(link);
link.click();
-
- // Cleanup
document.body.removeChild(link);
URL.revokeObjectURL(url);
-
- setStatus(`Project "${projectName}" exported as JSON file`);
-
+
+ setStatus(`Project "${projectName}" exported as KGStudio file`);
+
if (DEBUG_MODE.TOOLBAR) {
- console.log("KGStudio JSON export completed successfully");
+ console.log("KGStudio export completed successfully");
}
-
+
} catch (error) {
- console.error("Error exporting KGStudio JSON:", error);
+ console.error("Error exporting KGStudio file:", error);
setStatus(`Error exporting project: ${error}`);
- window.alert(`Failed to export project as JSON: ${error}`);
+ window.alert(`Failed to export project: ${error}`);
}
};
@@ -292,8 +285,11 @@ const Toolbar: React.FC = () => {
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
try {
- if (fileExtension === '.json') {
- // Handle KGStudio JSON import
+ if (fileExtension === '.kgstudio') {
+ // Handle KGStudio bundle import
+ await handleKGStudioFileImport(file);
+ } else if (fileExtension === '.json') {
+ // Handle legacy KGStudio JSON import
await handleKGStudioJSONImport(file);
} else if (fileExtension === '.mid' || fileExtension === '.midi') {
// Handle MIDI import
@@ -309,32 +305,64 @@ const Toolbar: React.FC = () => {
}
};
+ const handleKGStudioFileImport = async (file: File) => {
+ const storage = KGProjectStorage.getInstance();
+
+ try {
+ const projectName = await storage.importFromZip(file);
+
+ // Load the project from OPFS (this runs the upgrader)
+ const loaded = await storage.load(projectName);
+ if (!loaded) {
+ throw new Error('Failed to load imported project');
+ }
+
+ await loadProjectFromData(loaded, `KGStudio file "${file.name}"`);
+
+ if (DEBUG_MODE.TOOLBAR) {
+ console.log("KGStudio file imported successfully:", projectName);
+ }
+ } catch (error) {
+ window.alert(`The .kgstudio file is corrupted or invalid: ${error}`);
+ throw error;
+ }
+ };
+
const handleKGStudioJSONImport = async (file: File) => {
try {
- // Read the file content
const fileContent = await file.text();
-
const projectData = JSON.parse(fileContent);
-
- // Deserialize the project data using class-transformer (same as KGStorage)
+
+ // Deserialize and handle potential array return
const deserializedResult = plainToInstance(KGProject, projectData);
-
- // Handle case where plainToInstance might return an array
- const deserializedProject = Array.isArray(deserializedResult)
- ? deserializedResult[0] || null
+ const project = Array.isArray(deserializedResult)
+ ? deserializedResult[0] || null
: deserializedResult;
-
- if (!deserializedProject) {
+
+ if (!project) {
throw new Error("Failed to deserialize project data");
}
-
- // Load the project using common loading logic
- await loadProjectFromData(deserializedProject, `File "${file.name}"`);
-
- if (DEBUG_MODE.TOOLBAR) {
- console.log("KGStudio JSON project imported successfully:", deserializedProject);
+
+ // Use the project's name for the OPFS folder, auto-rename if it already exists
+ const storage = KGProjectStorage.getInstance();
+ const importedName = await storage.resolveUniqueName(project.getName() || 'Imported Project');
+
+ // Save to OPFS as a proper folder-based project
+ project.setName(importedName);
+ await storage.save(importedName, project, false);
+
+ // Load back from OPFS so the upgrader runs
+ const loaded = await storage.load(importedName);
+ if (!loaded) {
+ throw new Error('Failed to load imported project from storage');
}
-
+
+ await loadProjectFromData(loaded, `JSON file "${file.name}"`);
+
+ if (DEBUG_MODE.TOOLBAR) {
+ console.log("KGStudio JSON project imported and saved to OPFS:", importedName);
+ }
+
} catch (error) {
throw new Error(`Invalid KGStudio JSON file: ${error}`);
}
@@ -804,7 +832,7 @@ const Toolbar: React.FC = () => {
isVisible={showImportModal}
onClose={() => setShowImportModal(false)}
onFileImport={handleFileImport}
- acceptedTypes={['.json', '.mid', '.midi']}
+ acceptedTypes={['.kgstudio', '.json', '.mid', '.midi']}
title="Import Project"
description="Drag and drop your project file here"
/>
diff --git a/src/constants/coreConstants.ts b/src/constants/coreConstants.ts
index 13454b4..6db6c1d 100644
--- a/src/constants/coreConstants.ts
+++ b/src/constants/coreConstants.ts
@@ -93,6 +93,18 @@ export const SAMPLER_CONSTANTS = {
},
};
+export const OPFS_CONSTANTS = {
+ ROOT_DIR: 'projects',
+ PROJECT_FILE: 'project.json',
+ METADATA_FILE: 'meta.json',
+ MEDIA_DIR: 'media',
+};
+
+export const CONFIG_UPGRADER_CONSTANTS = {
+ VERSION_KEY: '__config_version',
+ CURRENT_VERSION: 1,
+};
+
export const URL_CONSTANTS = {
DEFAULT_OPENAI_BASE_URL: 'https://api.openai.com/v1',
};
diff --git a/src/core/KGCore.ts b/src/core/KGCore.ts
index 829e9d8..6ebdffd 100644
--- a/src/core/KGCore.ts
+++ b/src/core/KGCore.ts
@@ -2,6 +2,8 @@ import type { Selectable } from '../components/interfaces';
import { KGProject } from './KGProject';
import { KGAudioInterface } from './audio-interface/KGAudioInterface';
import { ConfigManager } from './config/ConfigManager';
+import { KGProjectStorage } from './io/KGProjectStorage';
+import { KGConfigUpgrader } from './config-upgrader/KGConfigUpgrader';
import { KGMidiRegion } from './region/KGMidiRegion';
import { KGMidiNote } from './midi/KGMidiNote';
import { KGRegion } from './region/KGRegion';
@@ -30,6 +32,8 @@ export class KGCore {
private copiedItems: Selectable[] = [];
private isPlaying: boolean = false;
+ private isMigrating: boolean = false;
+ private migrationStateChangeCallback: ((isMigrating: boolean) => void) | null = null;
// Timer management for playback
private playbackIntervalId: number | null = null;
@@ -71,11 +75,23 @@ export class KGCore {
// Initialize configuration manager
const configManager = ConfigManager.instance();
await configManager.initialize();
-
+
+ // Initialize OPFS project storage
+ const projectStorage = KGProjectStorage.getInstance();
+ await projectStorage.initialize();
+
+ // Run app-level migrations (e.g., IndexedDB -> OPFS project migration)
+ this.setMigrating(true);
+ try {
+ await KGConfigUpgrader.upgradeToLatest();
+ } finally {
+ this.setMigrating(false);
+ }
+
// Initialize audio interface
const audioInterface = KGAudioInterface.instance();
await audioInterface.initialize();
-
+
console.log("KGCore components initialized successfully");
} catch (error) {
console.error("Failed to initialize KGCore:", error);
@@ -194,7 +210,23 @@ export class KGCore {
this.selectionChangeCallbacks.forEach(callback => callback());
}
- // play
+ // Migration state
+ public getIsMigrating(): boolean {
+ return this.isMigrating;
+ }
+
+ private setMigrating(value: boolean): void {
+ this.isMigrating = value;
+ if (this.migrationStateChangeCallback) {
+ this.migrationStateChangeCallback(value);
+ }
+ }
+
+ public setMigrationStateChangeCallback(callback: (isMigrating: boolean) => void): void {
+ this.migrationStateChangeCallback = callback;
+ }
+
+ // play
public getIsPlaying(): boolean {
return this.isPlaying;
}
diff --git a/src/core/KGDebugger.ts b/src/core/KGDebugger.ts
index 23b7d52..7d01253 100644
--- a/src/core/KGDebugger.ts
+++ b/src/core/KGDebugger.ts
@@ -28,7 +28,8 @@ export class KGDebugger {
'createTestRegion()',
'testExtractXMLFromString(input)',
'testToolCall(jsonInput)',
- 'inputChatBox(content, interval?)'
+ 'inputChatBox(content, interval?)',
+ 'opfs(command)',
]);
}
@@ -344,6 +345,7 @@ export class KGDebugger {
console.log(" testExtractXMLFromString(input) - Test XML extraction from string");
console.log(" testToolCall(input) - Execute tool call(s) from JSON and show results");
console.log(" inputChatBox(content, interval?) - Type into ChatBox textarea and submit with Enter");
+ console.log(" opfs(command) - OPFS file browser (pwd, ls, cd, cat, dl)");
console.log(" help() - Show this help");
console.log("");
console.log("💡 Usage tips:");
@@ -446,4 +448,216 @@ export class KGDebugger {
console.error('❌ Error in inputChatBox:', error);
}
}
+
+ // --- OPFS Shell ---
+
+ /** Current working directory path segments (relative to OPFS root) */
+ private opfsCwd: string[] = [];
+
+ /**
+ * Simplified bash-like shell for browsing the OPFS filesystem.
+ *
+ * Supported commands:
+ * pwd — print current directory
+ * ls — list files/folders (like ls -lla)
+ * cd — change directory (supports .., /, relative, and quoted paths)
+ * cat — print file contents
+ * dl — download a file to your local machine
+ *
+ * Usage in console:
+ * await KGDebugger.opfs('pwd')
+ * await KGDebugger.opfs('ls')
+ * await KGDebugger.opfs('cd projects')
+ * await KGDebugger.opfs('cat project.json')
+ */
+ public async opfs(command: string): Promise {
+ const parts = command.trim().match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []
+ const cmd = parts[0]
+ // Strip surrounding quotes from arguments
+ const arg = parts.slice(1).map(p => p.replace(/^["']|["']$/g, '')).join(' ')
+
+ try {
+ switch (cmd) {
+ case 'pwd':
+ console.log('/' + this.opfsCwd.join('/'))
+ break
+
+ case 'ls':
+ await this.opfsLs()
+ break
+
+ case 'cd':
+ await this.opfsCd(arg)
+ break
+
+ case 'cat':
+ await this.opfsCat(arg)
+ break
+
+ case 'dl':
+ await this.opfsDl(arg)
+ break
+
+ default:
+ console.log(`opfs: command not found: ${cmd}`)
+ console.log('Available commands: pwd, ls, cd , cat , dl ')
+ }
+ } catch (error) {
+ console.error(`opfs: ${error}`)
+ }
+ }
+
+ private async opfsResolveCwd(): Promise {
+ let dir = await navigator.storage.getDirectory()
+ for (const segment of this.opfsCwd) {
+ dir = await dir.getDirectoryHandle(segment)
+ }
+ return dir
+ }
+
+ private async opfsLs(): Promise {
+ const dir = await this.opfsResolveCwd()
+ const entries: Array<{ kind: string; name: string; size: number; modified: string }> = []
+
+ for await (const entry of dir.values()) {
+ if (entry.kind === 'file') {
+ const fileHandle = entry as FileSystemFileHandle
+ const file = await fileHandle.getFile()
+ entries.push({
+ kind: 'file',
+ name: entry.name,
+ size: file.size,
+ modified: new Date(file.lastModified).toISOString().replace('T', ' ').slice(0, 19),
+ })
+ } else {
+ entries.push({
+ kind: 'dir',
+ name: entry.name + '/',
+ size: 0,
+ modified: '-',
+ })
+ }
+ }
+
+ // Sort: directories first, then files, alphabetically within each group
+ entries.sort((a, b) => {
+ if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1
+ return a.name.localeCompare(b.name)
+ })
+
+ if (entries.length === 0) {
+ console.log('(empty directory)')
+ return
+ }
+
+ // Print header
+ const cwdPath = '/' + this.opfsCwd.join('/')
+ console.log(`total ${entries.length} (${cwdPath})`)
+
+ // Format like ls -lla
+ const maxSizeLen = Math.max(...entries.map(e => String(e.size).length), 4)
+ for (const e of entries) {
+ const typeChar = e.kind === 'dir' ? 'd' : '-'
+ const perms = e.kind === 'dir' ? 'rwxr-xr-x' : 'rw-r--r--'
+ const sizeStr = e.kind === 'dir' ? '-'.padStart(maxSizeLen) : String(e.size).padStart(maxSizeLen)
+ console.log(`${typeChar}${perms} ${sizeStr} ${e.modified} ${e.name}`)
+ }
+ }
+
+ private async opfsCd(path: string): Promise {
+ if (!path || path === '') {
+ // cd with no args goes to root
+ this.opfsCwd = []
+ return
+ }
+
+ let segments: string[]
+
+ if (path === '/') {
+ this.opfsCwd = []
+ return
+ } else if (path.startsWith('/')) {
+ // Absolute path
+ segments = path.split('/').filter(Boolean)
+ } else {
+ // Relative path
+ segments = [...this.opfsCwd, ...path.split('/').filter(Boolean)]
+ }
+
+ // Resolve . and ..
+ const resolved: string[] = []
+ for (const seg of segments) {
+ if (seg === '.') continue
+ if (seg === '..') {
+ resolved.pop()
+ } else {
+ resolved.push(seg)
+ }
+ }
+
+ // Verify the path exists
+ let dir = await navigator.storage.getDirectory()
+ for (const seg of resolved) {
+ try {
+ dir = await dir.getDirectoryHandle(seg)
+ } catch {
+ console.error(`opfs: cd: no such directory: ${path}`)
+ return
+ }
+ }
+
+ this.opfsCwd = resolved
+ }
+
+ private async opfsCat(fileName: string): Promise {
+ if (!fileName) {
+ console.error('opfs: cat: missing file name')
+ return
+ }
+
+ const dir = await this.opfsResolveCwd()
+ try {
+ const fileHandle = await dir.getFileHandle(fileName)
+ const file = await fileHandle.getFile()
+ const text = await file.text()
+
+ // Pretty-print JSON files
+ if (fileName.endsWith('.json')) {
+ try {
+ const parsed = JSON.parse(text)
+ console.log(JSON.stringify(parsed, null, 2))
+ } catch {
+ console.log(text)
+ }
+ } else {
+ console.log(text)
+ }
+ } catch {
+ console.error(`opfs: cat: ${fileName}: No such file`)
+ }
+ }
+
+ private async opfsDl(fileName: string): Promise {
+ if (!fileName) {
+ console.error('opfs: dl: missing file name')
+ return
+ }
+
+ const dir = await this.opfsResolveCwd()
+ try {
+ const fileHandle = await dir.getFileHandle(fileName)
+ const file = await fileHandle.getFile()
+ const url = URL.createObjectURL(file)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = fileName
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ URL.revokeObjectURL(url)
+ console.log(`downloaded: ${fileName} (${file.size} bytes)`)
+ } catch {
+ console.error(`opfs: dl: ${fileName}: No such file`)
+ }
+ }
}
\ No newline at end of file
diff --git a/src/core/config-upgrader/KGConfigUpgrader.ts b/src/core/config-upgrader/KGConfigUpgrader.ts
new file mode 100644
index 0000000..63ee19f
--- /dev/null
+++ b/src/core/config-upgrader/KGConfigUpgrader.ts
@@ -0,0 +1,62 @@
+import { KGConfigStorage } from '../io/KGConfigStorage';
+import { CONFIG_UPGRADER_CONSTANTS } from '../../constants/coreConstants';
+import { upgradeConfigToV1 } from './upgradeConfigToV1';
+
+/**
+ * KGConfigUpgrader — Orchestrates app-level migrations (e.g., storage backend changes).
+ * Mirrors the KGProjectUpgrader pattern but operates on global app state, not individual projects.
+ *
+ * Version is tracked via a `__config_version` key in the IndexedDB config store.
+ */
+export class KGConfigUpgrader {
+ /**
+ * Run all pending config upgrades sequentially.
+ * Returns the number of upgrade steps that were executed.
+ */
+ public static async upgradeToLatest(): Promise {
+ const storage = KGConfigStorage.getInstance();
+ const currentVersion = await KGConfigUpgrader.getConfigVersion(storage);
+ const targetVersion = CONFIG_UPGRADER_CONSTANTS.CURRENT_VERSION;
+
+ if (currentVersion >= targetVersion) {
+ console.log(`Config is up to date (version ${currentVersion})`);
+ return 0;
+ }
+
+ console.log(`Config upgrade needed: v${currentVersion} -> v${targetVersion}`);
+
+ let stepsExecuted = 0;
+
+ for (let nextVersion = currentVersion + 1; nextVersion <= targetVersion; nextVersion++) {
+ switch (nextVersion) {
+ case 1: {
+ await upgradeConfigToV1();
+ break;
+ }
+ default: {
+ throw new Error(`No config upgrader found for version ${nextVersion}`);
+ }
+ }
+
+ // Persist the version after each successful step
+ await KGConfigUpgrader.setConfigVersion(storage, nextVersion);
+ stepsExecuted++;
+ console.log(`Config upgraded to version ${nextVersion}`);
+ }
+
+ return stepsExecuted;
+ }
+
+ private static async getConfigVersion(storage: KGConfigStorage): Promise {
+ const raw = await storage.getRaw(CONFIG_UPGRADER_CONSTANTS.VERSION_KEY);
+ if (!raw || typeof raw.version !== 'number') return 0;
+ return raw.version;
+ }
+
+ private static async setConfigVersion(storage: KGConfigStorage, version: number): Promise {
+ await storage.saveRaw(CONFIG_UPGRADER_CONSTANTS.VERSION_KEY, {
+ version,
+ upgradedAt: Date.now(),
+ });
+ }
+}
diff --git a/src/core/config-upgrader/upgradeConfigToV1.test.ts b/src/core/config-upgrader/upgradeConfigToV1.test.ts
new file mode 100644
index 0000000..cc99d2f
--- /dev/null
+++ b/src/core/config-upgrader/upgradeConfigToV1.test.ts
@@ -0,0 +1,180 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { instanceToPlain } from 'class-transformer';
+import { KGProject } from '../KGProject';
+
+// --- Mock idb for reading old IndexedDB projects ---
+const mockProjects: Array<{ name: string; data: Record; lastModified: number }> = [];
+
+vi.mock('idb', () => {
+ const stores: Record> = {};
+ const getStore = (name: string) => {
+ if (!stores[name]) stores[name] = new Map();
+ return stores[name];
+ };
+
+ return {
+ openDB: vi.fn(() => {
+ const db = {
+ getAll: vi.fn((storeName: string) => {
+ if (storeName === 'projects') return Promise.resolve([...mockProjects]);
+ return Promise.resolve([...getStore(storeName).values()]);
+ }),
+ get: vi.fn((storeName: string, key: string) => getStore(storeName).get(key)),
+ put: vi.fn((storeName: string, value: { name: string }) => {
+ getStore(storeName).set(value.name, value);
+ }),
+ close: vi.fn(),
+ objectStoreNames: { contains: () => false },
+ };
+ return Promise.resolve(db);
+ }),
+ __stores: stores,
+ __reset: () => Object.keys(stores).forEach((k) => delete stores[k]),
+ };
+});
+
+// --- Mock OPFS for KGProjectStorage ---
+class MockWritable {
+ data = '';
+ async write(content: string) { this.data = content; }
+ async close() {}
+}
+
+class MockFileHandle {
+ kind = 'file' as const;
+ private _content = '';
+ constructor(public name: string) {}
+ async getFile() { return { text: () => Promise.resolve(this._content) }; }
+ async createWritable() {
+ const w = new MockWritable();
+ const self = this;
+ const origClose = w.close.bind(w);
+ w.close = async () => { self._content = w.data; await origClose(); };
+ return w;
+ }
+}
+
+class MockDirHandle {
+ kind = 'directory' as const;
+ entries = new Map();
+ constructor(public name: string) {}
+
+ async getDirectoryHandle(name: string, opts?: { create?: boolean }) {
+ let e = this.entries.get(name);
+ if (!e || e.kind !== 'directory') {
+ if (opts?.create) {
+ e = new MockDirHandle(name);
+ this.entries.set(name, e);
+ } else {
+ throw new DOMException('Not found', 'NotFoundError');
+ }
+ }
+ return e as MockDirHandle;
+ }
+
+ async getFileHandle(name: string, opts?: { create?: boolean }) {
+ let e = this.entries.get(name);
+ if (!e || e.kind !== 'file') {
+ if (opts?.create) {
+ e = new MockFileHandle(name);
+ this.entries.set(name, e);
+ } else {
+ throw new DOMException('Not found', 'NotFoundError');
+ }
+ }
+ return e as MockFileHandle;
+ }
+
+ async removeEntry(name: string) { this.entries.delete(name); }
+
+ async *values() {
+ for (const e of this.entries.values()) yield e;
+ }
+}
+
+const mockRoot = new MockDirHandle('root');
+
+vi.stubGlobal('navigator', {
+ ...navigator,
+ storage: {
+ getDirectory: vi.fn(() => Promise.resolve(mockRoot)),
+ persist: vi.fn(() => Promise.resolve(true)),
+ },
+});
+
+// Now import the module under test
+import { upgradeConfigToV1 } from './upgradeConfigToV1';
+import { KGProjectStorage } from '../io/KGProjectStorage';
+
+describe('upgradeConfigToV1', () => {
+ beforeEach(async () => {
+ mockProjects.length = 0;
+ mockRoot.entries.clear();
+
+ // Reset KGProjectStorage singleton
+ ;(KGProjectStorage as unknown as { _instance: undefined })._instance = undefined;
+ const storage = KGProjectStorage.getInstance();
+ await storage.initialize();
+ });
+
+ it('migrates projects from IndexedDB to OPFS', async () => {
+ // Set up a mock IndexedDB project
+ const project = new KGProject('My Old Song', 32, 0, 130);
+ mockProjects.push({
+ name: 'My Old Song',
+ data: instanceToPlain(project) as Record,
+ lastModified: 1700000000000,
+ });
+
+ await upgradeConfigToV1();
+
+ const storage = KGProjectStorage.getInstance();
+ const names = await storage.list();
+ expect(names).toContain('My Old Song');
+
+ const loaded = await storage.load('My Old Song');
+ expect(loaded).not.toBeNull();
+ expect(loaded!.getBpm()).toBe(130);
+ });
+
+ it('sanitizes project names with invalid characters', async () => {
+ const project = new KGProject('My:Song/Here', 32, 0, 120);
+ mockProjects.push({
+ name: 'My:Song/Here',
+ data: instanceToPlain(project) as Record,
+ lastModified: Date.now(),
+ });
+
+ await upgradeConfigToV1();
+
+ const storage = KGProjectStorage.getInstance();
+ const names = await storage.list();
+ // Should be sanitized — no colons or slashes
+ expect(names).toContain('My_Song_Here');
+ });
+
+ it('skips projects that already exist in OPFS', async () => {
+ // Pre-create a project in OPFS
+ const storage = KGProjectStorage.getInstance();
+ const existing = new KGProject('Existing', 32, 0, 100);
+ await storage.save('Existing', existing);
+
+ // Add same project to IndexedDB mock
+ mockProjects.push({
+ name: 'Existing',
+ data: instanceToPlain(existing) as Record,
+ lastModified: Date.now(),
+ });
+
+ // Should not throw or overwrite
+ await upgradeConfigToV1();
+
+ const loaded = await storage.load('Existing');
+ expect(loaded!.getBpm()).toBe(100); // Original BPM preserved
+ });
+
+ it('handles empty IndexedDB gracefully', async () => {
+ // No projects in IndexedDB
+ await expect(upgradeConfigToV1()).resolves.not.toThrow();
+ });
+});
diff --git a/src/core/config-upgrader/upgradeConfigToV1.ts b/src/core/config-upgrader/upgradeConfigToV1.ts
new file mode 100644
index 0000000..5edbb65
--- /dev/null
+++ b/src/core/config-upgrader/upgradeConfigToV1.ts
@@ -0,0 +1,144 @@
+import { openDB } from 'idb';
+import { plainToInstance } from 'class-transformer';
+import { KGProject } from '../KGProject';
+import { KGProjectStorage } from '../io/KGProjectStorage';
+import { upgradeProjectToLatest } from '../project-upgrader/KGProjectUpgrader';
+import { sanitizeProjectName } from '../../util/projectNameUtil';
+import { DB_CONSTANTS } from '../../constants/coreConstants';
+
+/**
+ * Config upgrade V1: Migrate all projects from IndexedDB to OPFS.
+ *
+ * This reads directly from the old IndexedDB `projects` store (no dependency on KGStorage),
+ * sanitizes project names, and writes each project to the OPFS-backed KGProjectStorage.
+ *
+ * Idempotent: projects already present in OPFS are skipped.
+ * Non-destructive: old IndexedDB data is preserved as a backup.
+ */
+export async function upgradeConfigToV1(): Promise {
+ console.log('Config V1 upgrade: migrating projects from IndexedDB to OPFS...');
+
+ // Read all projects from the old IndexedDB store
+ const oldProjects = await readAllProjectsFromIndexedDB();
+
+ if (oldProjects.length === 0) {
+ console.log('Config V1 upgrade: no projects found in IndexedDB, nothing to migrate');
+ return;
+ }
+
+ const projectStorage = KGProjectStorage.getInstance();
+ let migrated = 0;
+ let skipped = 0;
+ const errors: string[] = [];
+
+ for (const { name, data, lastModified } of oldProjects) {
+ try {
+ // Deserialize the project
+ const instance = plainToInstance(KGProject, data);
+ const project = Array.isArray(instance) ? instance[0] : instance;
+ if (!project) {
+ errors.push(`${name}: deserialization returned null`);
+ continue;
+ }
+
+ // Sanitize the project name for filesystem use
+ const sanitizedName = sanitizeProjectName(name);
+ project.setName(sanitizedName);
+
+ // Run the project upgrader (handles schema changes like instrument mapping, etc.)
+ const upgradedProject = upgradeProjectToLatest(project);
+
+ // Skip if already exists in OPFS
+ if (await projectStorage.exists(sanitizedName)) {
+ skipped++;
+ continue;
+ }
+
+ // Save to OPFS — use overwrite=false since we checked exists() above
+ // We call save directly which writes meta.json with createdAt = now.
+ // Override createdAt with the original lastModified from IndexedDB afterwards.
+ await projectStorage.save(sanitizedName, upgradedProject, false);
+
+ // Patch meta.json to use the original lastModified as createdAt
+ if (lastModified) {
+ await patchMetaCreatedAt(projectStorage, sanitizedName, lastModified);
+ }
+
+ migrated++;
+ } catch (error) {
+ errors.push(`${name}: ${error}`);
+ console.error(`Config V1 upgrade: failed to migrate project "${name}":`, error);
+ }
+ }
+
+ console.log(
+ `Config V1 upgrade complete: ${migrated} migrated, ${skipped} skipped, ${errors.length} errors`,
+ );
+ if (errors.length > 0) {
+ console.warn('Config V1 upgrade errors:', errors);
+ }
+}
+
+// --- Internal helpers ---
+
+interface OldProjectEntry {
+ name: string;
+ data: Record;
+ lastModified: number;
+}
+
+/**
+ * Read all projects from the old IndexedDB store directly (no KGStorage dependency).
+ */
+async function readAllProjectsFromIndexedDB(): Promise {
+ try {
+ const db = await openDB(DB_CONSTANTS.DB_NAME, DB_CONSTANTS.DB_VERSION, {
+ upgrade(db) {
+ // Ensure stores exist (same logic as old KGStorage)
+ const requiredStores = [DB_CONSTANTS.PROJECTS_STORE_NAME, DB_CONSTANTS.CONFIG_STORE_NAME];
+ for (const store of requiredStores) {
+ if (!db.objectStoreNames.contains(store)) {
+ db.createObjectStore(store, { keyPath: 'name' });
+ }
+ }
+ },
+ });
+
+ const allEntries = await db.getAll(DB_CONSTANTS.PROJECTS_STORE_NAME);
+ db.close();
+
+ return allEntries.map((entry) => ({
+ name: entry.name as string,
+ data: entry.data as Record,
+ lastModified: (entry.lastModified as number) ?? Date.now(),
+ }));
+ } catch (error) {
+ console.error('Config V1 upgrade: failed to read from IndexedDB:', error);
+ return [];
+ }
+}
+
+/**
+ * Patch a project's meta.json to set createdAt to the original IndexedDB lastModified.
+ * This is a best-effort operation — we access OPFS directly for this one-time patch.
+ */
+async function patchMetaCreatedAt(
+ projectStorage: KGProjectStorage,
+ projectName: string,
+ createdAt: number,
+): Promise {
+ try {
+ const root = await navigator.storage.getDirectory();
+ const projectsDir = await root.getDirectoryHandle('projects');
+ const projectDir = await projectsDir.getDirectoryHandle(projectName);
+ const metaHandle = await projectDir.getFileHandle('meta.json');
+ const file = await metaHandle.getFile();
+ const meta = JSON.parse(await file.text());
+ meta.createdAt = createdAt;
+ const writable = await metaHandle.createWritable();
+ await writable.write(JSON.stringify(meta, null, 2));
+ await writable.close();
+ } catch {
+ // Non-critical — createdAt will just be the migration time
+ }
+}
diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts
index 8e17884..2b40a38 100644
--- a/src/core/config/ConfigManager.ts
+++ b/src/core/config/ConfigManager.ts
@@ -1,5 +1,4 @@
-import { KGStorage } from '../io/KGStorage';
-import { DB_CONSTANTS } from '../../constants/coreConstants';
+import { KGConfigStorage } from '../io/KGConfigStorage';
/**
* Application configuration interface
@@ -99,7 +98,7 @@ export class ConfigManager {
// Configuration state
private config: AppConfig;
- private storage: KGStorage;
+ private storage: KGConfigStorage;
private isInitialized: boolean = false;
private defaultConfig: AppConfig | null = null;
private changeListeners: Set<(changedKeys: string[]) => void> = new Set();
@@ -108,7 +107,7 @@ export class ConfigManager {
private constructor() {
// Initialize with empty config, will be loaded during initialize()
this.config = {} as AppConfig;
- this.storage = KGStorage.getInstance();
+ this.storage = KGConfigStorage.getInstance();
console.log('ConfigManager initialized');
}
@@ -262,11 +261,8 @@ export class ConfigManager {
// For config, we don't use class-transformer since it's plain objects
// So we'll use a simple object approach and handle it directly with KGStorage
const savedConfigData = await this.storage.load(
- DB_CONSTANTS.DB_NAME,
- DB_CONSTANTS.CONFIG_STORE_NAME,
ConfigManager.CONFIG_KEY,
Object, // Simple object class
- DB_CONSTANTS.DB_VERSION
);
if (savedConfigData) {
@@ -294,12 +290,9 @@ export class ConfigManager {
: this.config;
await this.storage.save(
- DB_CONSTANTS.DB_NAME,
- DB_CONSTANTS.CONFIG_STORE_NAME,
ConfigManager.CONFIG_KEY,
configToPersist,
true, // Always overwrite config
- DB_CONSTANTS.DB_VERSION
);
console.log('Saved config to storage:', configToPersist);
} catch (error) {
diff --git a/src/core/io/KGConfigStorage.test.ts b/src/core/io/KGConfigStorage.test.ts
new file mode 100644
index 0000000..3dbcc0f
--- /dev/null
+++ b/src/core/io/KGConfigStorage.test.ts
@@ -0,0 +1,86 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+
+// Mock idb before importing KGConfigStorage
+vi.mock('idb', () => {
+ const stores: Record> = {};
+
+ const getStore = (name: string): Map => {
+ if (!stores[name]) stores[name] = new Map();
+ return stores[name];
+ };
+
+ const mockDB = {
+ get: vi.fn((storeName: string, key: string) => {
+ return getStore(storeName).get(key) ?? undefined;
+ }),
+ put: vi.fn((storeName: string, value: { name: string }) => {
+ getStore(storeName).set(value.name, value);
+ }),
+ delete: vi.fn((storeName: string, key: string) => {
+ getStore(storeName).delete(key);
+ }),
+ objectStoreNames: { contains: () => false },
+ };
+
+ return {
+ openDB: vi.fn(() => Promise.resolve(mockDB)),
+ __stores: stores,
+ __reset: () => {
+ Object.keys(stores).forEach((k) => delete stores[k]);
+ },
+ };
+});
+
+import { KGConfigStorage } from './KGConfigStorage';
+
+// Access mock internals
+const idbMock = await import('idb') as unknown as {
+ __stores: Record>;
+ __reset: () => void;
+};
+
+describe('KGConfigStorage', () => {
+ let storage: KGConfigStorage;
+
+ beforeEach(() => {
+ idbMock.__reset();
+ // Reset singleton for test isolation
+ ;(KGConfigStorage as unknown as { _instance: undefined })._instance = undefined;
+ storage = KGConfigStorage.getInstance();
+ });
+
+ it('saves and loads a config entry', async () => {
+ await storage.save('testKey', { foo: 'bar' }, true);
+ const result = await storage.load('testKey', Object);
+
+ expect(result).toBeDefined();
+ expect((result as Record).foo).toBe('bar');
+ });
+
+ it('deletes a config entry', async () => {
+ await storage.save('toDelete', { x: 1 }, true);
+ await storage.delete('toDelete');
+ const result = await storage.load('toDelete', Object);
+
+ expect(result).toBeNull();
+ });
+
+ it('saveRaw and getRaw work for version markers', async () => {
+ await storage.saveRaw('__config_version', { version: 1, upgradedAt: 123 });
+ const raw = await storage.getRaw('__config_version');
+
+ expect(raw).toBeDefined();
+ expect(raw!.version).toBe(1);
+ });
+
+ it('getRaw returns null for non-existent key', async () => {
+ const result = await storage.getRaw('nonexistent');
+ expect(result).toBeNull();
+ });
+
+ it('returns singleton instance', () => {
+ const a = KGConfigStorage.getInstance();
+ const b = KGConfigStorage.getInstance();
+ expect(a).toBe(b);
+ });
+});
diff --git a/src/core/io/KGConfigStorage.ts b/src/core/io/KGConfigStorage.ts
new file mode 100644
index 0000000..bb9c3d2
--- /dev/null
+++ b/src/core/io/KGConfigStorage.ts
@@ -0,0 +1,119 @@
+import { openDB } from 'idb';
+import type { IDBPDatabase } from 'idb';
+import { instanceToPlain, plainToInstance } from 'class-transformer';
+import { DB_CONSTANTS } from '../../constants/coreConstants';
+
+interface ConfigStorageEntry {
+ name: string;
+ data: Record;
+ lastModified: number;
+}
+
+/**
+ * KGConfigStorage — IndexedDB-backed storage for application configuration.
+ * Extracted from the former KGStorage class; only manages the config object store.
+ */
+export class KGConfigStorage {
+ private static _instance: KGConfigStorage;
+ private dbPromise: Promise | null = null;
+
+ private constructor() {}
+
+ public static getInstance(): KGConfigStorage {
+ if (!KGConfigStorage._instance) {
+ KGConfigStorage._instance = new KGConfigStorage();
+ }
+ return KGConfigStorage._instance;
+ }
+
+ private getDB(): Promise {
+ if (!this.dbPromise) {
+ this.dbPromise = openDB(DB_CONSTANTS.DB_NAME, DB_CONSTANTS.DB_VERSION, {
+ upgrade(db) {
+ // Create required object stores if they don't exist
+ const requiredStores = [
+ DB_CONSTANTS.PROJECTS_STORE_NAME,
+ DB_CONSTANTS.CONFIG_STORE_NAME,
+ ];
+ for (const store of requiredStores) {
+ if (!db.objectStoreNames.contains(store)) {
+ db.createObjectStore(store, { keyPath: 'name' });
+ console.log(`Created object store: ${store}`);
+ }
+ }
+ },
+ });
+ }
+ return this.dbPromise;
+ }
+
+ public async save(name: string, data: unknown, overwrite: boolean = true): Promise {
+ const db = await this.getDB();
+ const storeName = DB_CONSTANTS.CONFIG_STORE_NAME;
+
+ if (!overwrite) {
+ const existing = await db.get(storeName, name);
+ if (existing) {
+ throw new Error(`Config entry "${name}" already exists`);
+ }
+ }
+
+ const entry: ConfigStorageEntry = {
+ name,
+ data: instanceToPlain(data) as Record,
+ lastModified: Date.now(),
+ };
+ await db.put(storeName, entry);
+ }
+
+ public async load(name: string, classType: new () => T): Promise {
+ try {
+ const db = await this.getDB();
+ const entry = await db.get(DB_CONSTANTS.CONFIG_STORE_NAME, name);
+
+ if (!entry?.data) {
+ return null;
+ }
+
+ const instance = plainToInstance(classType, entry.data);
+ return Array.isArray(instance) ? instance[0] || null : instance;
+ } catch (error) {
+ console.error(`Error loading config entry "${name}":`, error);
+ return null;
+ }
+ }
+
+ public async delete(name: string): Promise {
+ const db = await this.getDB();
+ await db.delete(DB_CONSTANTS.CONFIG_STORE_NAME, name);
+ }
+
+ /**
+ * Get a raw value from the config store (no class-transformer deserialization).
+ * Used by KGConfigUpgrader to read the config version marker.
+ */
+ public async getRaw(name: string): Promise | null> {
+ try {
+ const db = await this.getDB();
+ const entry = await db.get(DB_CONSTANTS.CONFIG_STORE_NAME, name);
+ return entry?.data ?? null;
+ } catch (error) {
+ console.error(`Error loading raw config entry "${name}":`, error);
+ return null;
+ }
+ }
+
+ /**
+ * Save a raw value to the config store (no class-transformer serialization).
+ * Used by KGConfigUpgrader to write the config version marker.
+ */
+ public async saveRaw(name: string, data: Record): Promise {
+ const db = await this.getDB();
+ const entry: ConfigStorageEntry = {
+ name,
+ data,
+ lastModified: Date.now(),
+ };
+ await db.put(DB_CONSTANTS.CONFIG_STORE_NAME, entry);
+ }
+}
diff --git a/src/core/io/KGProjectStorage.test.ts b/src/core/io/KGProjectStorage.test.ts
new file mode 100644
index 0000000..3ded8e9
--- /dev/null
+++ b/src/core/io/KGProjectStorage.test.ts
@@ -0,0 +1,230 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { KGProjectStorage, DuplicateEntryError } from './KGProjectStorage';
+import { KGProject } from '../KGProject';
+
+// --- OPFS mock infrastructure ---
+
+class MockFileSystemWritableFileStream {
+ public data = '';
+ async write(content: string) { this.data = content; }
+ async close() {}
+}
+
+class MockFileSystemFileHandle {
+ kind = 'file' as const;
+ constructor(public name: string, private _content: string = '') {}
+ async getFile() {
+ return { text: () => Promise.resolve(this._content) };
+ }
+ async createWritable() {
+ const stream = new MockFileSystemWritableFileStream();
+ // When stream closes, update our content
+ const self = this;
+ const origClose = stream.close.bind(stream);
+ stream.close = async () => {
+ self._content = stream.data;
+ await origClose();
+ };
+ return stream;
+ }
+}
+
+class MockFileSystemDirectoryHandle {
+ kind = 'directory' as const;
+ private entries = new Map();
+
+ constructor(public name: string) {}
+
+ async getDirectoryHandle(name: string, options?: { create?: boolean }): Promise {
+ let entry = this.entries.get(name);
+ if (!entry || entry.kind !== 'directory') {
+ if (options?.create) {
+ entry = new MockFileSystemDirectoryHandle(name);
+ this.entries.set(name, entry);
+ } else {
+ throw new DOMException(`Directory "${name}" not found`, 'NotFoundError');
+ }
+ }
+ return entry as MockFileSystemDirectoryHandle;
+ }
+
+ async getFileHandle(name: string, options?: { create?: boolean }): Promise {
+ let entry = this.entries.get(name);
+ if (!entry || entry.kind !== 'file') {
+ if (options?.create) {
+ entry = new MockFileSystemFileHandle(name);
+ this.entries.set(name, entry);
+ } else {
+ throw new DOMException(`File "${name}" not found`, 'NotFoundError');
+ }
+ }
+ return entry as MockFileSystemFileHandle;
+ }
+
+ async removeEntry(name: string, _options?: { recursive?: boolean }): Promise {
+ if (!this.entries.has(name)) {
+ throw new DOMException(`Entry "${name}" not found`, 'NotFoundError');
+ }
+ this.entries.delete(name);
+ }
+
+ async *values(): AsyncIterableIterator {
+ for (const entry of this.entries.values()) {
+ yield entry;
+ }
+ }
+}
+
+// Install the mock
+const mockRoot = new MockFileSystemDirectoryHandle('root');
+
+vi.stubGlobal('navigator', {
+ ...navigator,
+ storage: {
+ getDirectory: vi.fn(() => Promise.resolve(mockRoot)),
+ persist: vi.fn(() => Promise.resolve(true)),
+ estimate: vi.fn(() => Promise.resolve({ usage: 0, quota: 1e9 })),
+ },
+});
+
+describe('KGProjectStorage', () => {
+ let storage: KGProjectStorage;
+
+ beforeEach(async () => {
+ // Reset singleton and mock filesystem
+ ;(KGProjectStorage as unknown as { _instance: undefined })._instance = undefined;
+ // Clear the mock root directory entries
+ const entries = (mockRoot as unknown as { entries: Map }).entries;
+ entries.clear();
+
+ storage = KGProjectStorage.getInstance();
+ await storage.initialize();
+ });
+
+ function createTestProject(name = 'Test Project'): KGProject {
+ return new KGProject(name, 16, 0, 120);
+ }
+
+ it('initializes and creates the projects directory', async () => {
+ // The projects directory should exist after init
+ const projects = await mockRoot.getDirectoryHandle('projects');
+ expect(projects).toBeDefined();
+ expect(projects.kind).toBe('directory');
+ });
+
+ it('saves and loads a project', async () => {
+ const project = createTestProject('My Song');
+ await storage.save('My Song', project);
+
+ const loaded = await storage.load('My Song');
+ expect(loaded).not.toBeNull();
+ expect(loaded!.getName()).toBe('My Song');
+ expect(loaded!.getBpm()).toBe(120);
+ });
+
+ it('creates meta.json and media/ directory on save', async () => {
+ const project = createTestProject('My Song');
+ await storage.save('My Song', project);
+
+ const projectsDir = await mockRoot.getDirectoryHandle('projects');
+ const projectDir = await projectsDir.getDirectoryHandle('My Song');
+
+ // meta.json should exist
+ const metaHandle = await projectDir.getFileHandle('meta.json');
+ const metaFile = await metaHandle.getFile();
+ const meta = JSON.parse(await metaFile.text());
+ expect(meta.name).toBe('My Song');
+ expect(meta.createdAt).toBeGreaterThan(0);
+ expect(meta.updatedAt).toBeGreaterThan(0);
+
+ // media/ directory should exist
+ const mediaDir = await projectDir.getDirectoryHandle('media');
+ expect(mediaDir.kind).toBe('directory');
+ });
+
+ it('throws DuplicateEntryError when overwrite is false', async () => {
+ const project = createTestProject('Duplicate');
+ await storage.save('Duplicate', project);
+
+ await expect(storage.save('Duplicate', project, false)).rejects.toThrow(DuplicateEntryError);
+ });
+
+ it('allows overwrite when overwrite is true', async () => {
+ const project = createTestProject('Overwrite Test');
+ await storage.save('Overwrite Test', project);
+
+ project.setBpm(140);
+ await storage.save('Overwrite Test', project, true);
+
+ const loaded = await storage.load('Overwrite Test');
+ expect(loaded!.getBpm()).toBe(140);
+ });
+
+ it('preserves createdAt on overwrite', async () => {
+ const project = createTestProject('Preserve');
+ await storage.save('Preserve', project);
+
+ // Read the original createdAt
+ const projectsDir = await mockRoot.getDirectoryHandle('projects');
+ const projectDir = await projectsDir.getDirectoryHandle('Preserve');
+ const metaHandle1 = await projectDir.getFileHandle('meta.json');
+ const meta1 = JSON.parse(await (await metaHandle1.getFile()).text());
+
+ // Save again (overwrite)
+ await storage.save('Preserve', project, true);
+
+ const metaHandle2 = await projectDir.getFileHandle('meta.json');
+ const meta2 = JSON.parse(await (await metaHandle2.getFile()).text());
+
+ expect(meta2.createdAt).toBe(meta1.createdAt);
+ expect(meta2.updatedAt).toBeGreaterThanOrEqual(meta1.updatedAt);
+ });
+
+ it('lists project names', async () => {
+ await storage.save('Alpha', createTestProject('Alpha'));
+ await storage.save('Beta', createTestProject('Beta'));
+ await storage.save('Charlie', createTestProject('Charlie'));
+
+ const names = await storage.list();
+ expect(names).toEqual(['Alpha', 'Beta', 'Charlie']);
+ });
+
+ it('checks if project exists', async () => {
+ expect(await storage.exists('Nonexistent')).toBe(false);
+
+ await storage.save('Exists', createTestProject('Exists'));
+ expect(await storage.exists('Exists')).toBe(true);
+ });
+
+ it('deletes a project', async () => {
+ await storage.save('ToDelete', createTestProject('ToDelete'));
+ expect(await storage.exists('ToDelete')).toBe(true);
+
+ await storage.delete('ToDelete');
+ expect(await storage.exists('ToDelete')).toBe(false);
+ });
+
+ it('returns null for non-existent project on load', async () => {
+ const result = await storage.load('Ghost');
+ expect(result).toBeNull();
+ });
+
+ it('rejects invalid project names on save', async () => {
+ const project = createTestProject();
+ await expect(storage.save('my/song', project)).rejects.toThrow('Invalid project name');
+ await expect(storage.save('my:song', project)).rejects.toThrow('Invalid project name');
+ await expect(storage.save('', project)).rejects.toThrow('Invalid project name');
+ });
+
+ it('renames a project', async () => {
+ await storage.save('Old Name', createTestProject('Old Name'));
+
+ await storage.rename('Old Name', 'New Name');
+
+ expect(await storage.exists('Old Name')).toBe(false);
+ expect(await storage.exists('New Name')).toBe(true);
+
+ const loaded = await storage.load('New Name');
+ expect(loaded!.getName()).toBe('New Name');
+ });
+});
diff --git a/src/core/io/KGProjectStorage.ts b/src/core/io/KGProjectStorage.ts
new file mode 100644
index 0000000..ed92db8
--- /dev/null
+++ b/src/core/io/KGProjectStorage.ts
@@ -0,0 +1,398 @@
+import { instanceToPlain, plainToInstance } from 'class-transformer';
+import JSZip from 'jszip';
+import { KGProject } from '../KGProject';
+import { upgradeProjectToLatest } from '../project-upgrader/KGProjectUpgrader';
+import { isValidProjectName } from '../../util/projectNameUtil';
+import { OPFS_CONSTANTS } from '../../constants/coreConstants';
+
+export class DuplicateEntryError extends Error {
+ constructor(name: string) {
+ super(`Entry "${name}" already exists`);
+ this.name = 'DuplicateEntryError';
+ }
+}
+
+interface ProjectMeta {
+ name: string;
+ createdAt: number;
+ updatedAt: number;
+}
+
+/**
+ * KGProjectStorage — OPFS-backed storage for project files.
+ * Each project lives in its own directory under the OPFS `projects/` root.
+ *
+ * Folder structure:
+ * projects//meta.json
+ * projects//project.json
+ * projects//media/
+ */
+export class KGProjectStorage {
+ private static _instance: KGProjectStorage;
+ private rootDirHandle: FileSystemDirectoryHandle | null = null;
+ private projectsDirHandle: FileSystemDirectoryHandle | null = null;
+ private _initialized = false;
+
+ private constructor() {}
+
+ public static getInstance(): KGProjectStorage {
+ if (!KGProjectStorage._instance) {
+ KGProjectStorage._instance = new KGProjectStorage();
+ }
+ return KGProjectStorage._instance;
+ }
+
+ /**
+ * Initialize OPFS root and request persistent storage.
+ * Must be called before any other method.
+ */
+ public async initialize(): Promise {
+ if (this._initialized) return;
+
+ this.rootDirHandle = await navigator.storage.getDirectory();
+ this.projectsDirHandle = await this.rootDirHandle.getDirectoryHandle(
+ OPFS_CONSTANTS.ROOT_DIR,
+ { create: true },
+ );
+
+ // Request persistent storage so the browser won't evict our data
+ try {
+ const persisted = await navigator.storage.persist();
+ console.log(`Persistent storage ${persisted ? 'granted' : 'denied'}`);
+ } catch (error) {
+ console.warn('navigator.storage.persist() not available:', error);
+ }
+
+ this._initialized = true;
+ console.log('KGProjectStorage initialized (OPFS)');
+ }
+
+ private ensureInitialized(): void {
+ if (!this._initialized || !this.projectsDirHandle) {
+ throw new Error('KGProjectStorage not initialized. Call initialize() first.');
+ }
+ }
+
+ /**
+ * Save a project. Creates the folder structure and writes meta.json + project.json.
+ */
+ public async save(name: string, data: KGProject, overwrite: boolean = false): Promise {
+ this.ensureInitialized();
+
+ if (!isValidProjectName(name)) {
+ throw new Error(
+ `Invalid project name "${name}". Only letters, numbers, spaces, hyphens, underscores, periods, and parentheses are allowed.`,
+ );
+ }
+
+ const exists = await this.exists(name);
+ if (exists && !overwrite) {
+ throw new DuplicateEntryError(name);
+ }
+
+ const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name, { create: true });
+
+ // Ensure media/ directory exists
+ await projectDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR, { create: true });
+
+ // Write project.json
+ const projectData = instanceToPlain(data) as Record;
+ const projectJson = JSON.stringify(projectData, null, 2);
+ await this.writeFile(projectDir, OPFS_CONSTANTS.PROJECT_FILE, projectJson);
+
+ // Write/update meta.json
+ const now = Date.now();
+ let meta: ProjectMeta;
+ try {
+ const existingMeta = await this.readFile(projectDir, OPFS_CONSTANTS.METADATA_FILE);
+ const parsed = JSON.parse(existingMeta) as ProjectMeta;
+ meta = { name, createdAt: parsed.createdAt, updatedAt: now };
+ } catch {
+ meta = { name, createdAt: now, updatedAt: now };
+ }
+ await this.writeFile(projectDir, OPFS_CONSTANTS.METADATA_FILE, JSON.stringify(meta, null, 2));
+ }
+
+ /**
+ * Load a project by name. Runs the project upgrader on the loaded data.
+ */
+ public async load(name: string): Promise {
+ this.ensureInitialized();
+
+ try {
+ const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name);
+ const projectJson = await this.readFile(projectDir, OPFS_CONSTANTS.PROJECT_FILE);
+ const plainData = JSON.parse(projectJson);
+
+ const instance = plainToInstance(KGProject, plainData);
+ const project = Array.isArray(instance) ? instance[0] || null : instance;
+
+ if (!project) return null;
+
+ project.setName(name);
+ return upgradeProjectToLatest(project);
+ } catch (error) {
+ console.error(`Error loading project "${name}":`, error);
+ return null;
+ }
+ }
+
+ /**
+ * List all project names (folder names under projects/).
+ */
+ public async list(): Promise {
+ this.ensureInitialized();
+
+ const names: string[] = [];
+ // FileSystemDirectoryHandle.entries() returns AsyncIterableIterator
+ // TypeScript's lib.dom.d.ts may lack full typing for this, so we iterate via values()
+ for await (const entry of this.projectsDirHandle!.values()) {
+ if (entry.kind === 'directory') {
+ names.push(entry.name);
+ }
+ }
+ return names.sort();
+ }
+
+ /**
+ * Delete a project and all its files.
+ */
+ public async delete(name: string): Promise {
+ this.ensureInitialized();
+
+ try {
+ await this.projectsDirHandle!.removeEntry(name, { recursive: true });
+ } catch (error) {
+ console.error(`Error deleting project "${name}":`, error);
+ throw error;
+ }
+ }
+
+ /**
+ * Check if a project exists.
+ */
+ public async exists(name: string): Promise {
+ this.ensureInitialized();
+
+ try {
+ await this.projectsDirHandle!.getDirectoryHandle(name);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ /**
+ * Rename a project by copying its directory contents to a new name and deleting the old one.
+ */
+ public async rename(oldName: string, newName: string): Promise {
+ this.ensureInitialized();
+
+ if (!isValidProjectName(newName)) {
+ throw new Error(`Invalid project name "${newName}".`);
+ }
+
+ if (await this.exists(newName)) {
+ throw new DuplicateEntryError(newName);
+ }
+
+ // Load the project from the old location
+ const project = await this.load(oldName);
+ if (!project) {
+ throw new Error(`Project "${oldName}" not found.`);
+ }
+
+ // Save to new location
+ project.setName(newName);
+ await this.save(newName, project, false);
+
+ // Delete old location
+ await this.delete(oldName);
+ }
+
+ /**
+ * Export a project folder as a zip Blob (.kgstudio bundle).
+ * Includes project.json, meta.json, and all files in media/.
+ */
+ public async exportAsZip(name: string): Promise {
+ this.ensureInitialized();
+
+ const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name);
+ const zip = new JSZip();
+
+ await this.addDirectoryToZip(zip, projectDir);
+
+ return zip.generateAsync({ type: 'blob' });
+ }
+
+ /**
+ * Recursively add all files and subdirectories from an OPFS directory to a JSZip instance.
+ */
+ private async addDirectoryToZip(
+ zip: JSZip,
+ dirHandle: FileSystemDirectoryHandle,
+ path: string = '',
+ ): Promise {
+ for await (const entry of dirHandle.values()) {
+ const entryPath = path ? `${path}/${entry.name}` : entry.name;
+
+ if (entry.kind === 'file') {
+ const fileHandle = entry as FileSystemFileHandle;
+ const file = await fileHandle.getFile();
+ zip.file(entryPath, file.arrayBuffer());
+ } else {
+ const subDir = entry as FileSystemDirectoryHandle;
+ await this.addDirectoryToZip(zip, subDir, entryPath);
+ }
+ }
+ }
+
+ /**
+ * Import a .kgstudio zip bundle into OPFS.
+ * Validates that meta.json exists and is valid.
+ * Returns the project name on success.
+ * On failure, cleans up any partially written folder and throws.
+ */
+ public async importFromZip(blob: Blob): Promise {
+ this.ensureInitialized();
+
+ const zip = await JSZip.loadAsync(blob);
+
+ // Validate meta.json
+ const metaFile = zip.file(OPFS_CONSTANTS.METADATA_FILE);
+ if (!metaFile) {
+ throw new Error('Invalid .kgstudio file: missing meta.json');
+ }
+
+ let meta: { name?: string };
+ try {
+ const metaText = await metaFile.async('text');
+ meta = JSON.parse(metaText);
+ } catch {
+ throw new Error('Invalid .kgstudio file: meta.json is corrupted');
+ }
+
+ if (!meta.name || typeof meta.name !== 'string') {
+ throw new Error('Invalid .kgstudio file: meta.json missing project name');
+ }
+
+ const projectName = await this.resolveUniqueName(meta.name);
+
+ const projectDir = await this.projectsDirHandle!.getDirectoryHandle(projectName, { create: true });
+
+ try {
+ // Write all files from the zip into the OPFS project directory
+ for (const [relativePath, zipEntry] of Object.entries(zip.files)) {
+ if (zipEntry.dir) {
+ // Create subdirectory
+ await this.getOrCreateSubDir(projectDir, relativePath);
+ } else {
+ // Write file
+ const data = await zipEntry.async('arraybuffer');
+ const parts = relativePath.split('/');
+ const fileName = parts.pop()!;
+
+ let targetDir = projectDir;
+ if (parts.length > 0) {
+ targetDir = await this.getOrCreateSubDir(projectDir, parts.join('/'));
+ }
+
+ const fileHandle = await targetDir.getFileHandle(fileName, { create: true });
+ const writable = await fileHandle.createWritable();
+ await writable.write(data);
+ await writable.close();
+ }
+ }
+
+ // If the name was deduplicated, update meta.json and project.json to reflect it
+ if (projectName !== meta.name) {
+ // Patch meta.json
+ try {
+ const metaHandle = await projectDir.getFileHandle(OPFS_CONSTANTS.METADATA_FILE);
+ const metaFileObj = await metaHandle.getFile();
+ const metaData = JSON.parse(await metaFileObj.text());
+ metaData.name = projectName;
+ const w1 = await metaHandle.createWritable();
+ await w1.write(JSON.stringify(metaData, null, 2));
+ await w1.close();
+ } catch { /* best effort */ }
+
+ // Patch project.json name field
+ try {
+ const projHandle = await projectDir.getFileHandle(OPFS_CONSTANTS.PROJECT_FILE);
+ const projFileObj = await projHandle.getFile();
+ const projData = JSON.parse(await projFileObj.text());
+ projData.name = projectName;
+ const w2 = await projHandle.createWritable();
+ await w2.write(JSON.stringify(projData, null, 2));
+ await w2.close();
+ } catch { /* best effort */ }
+ }
+
+ return projectName;
+ } catch (error) {
+ // Clean up the partially written folder
+ try {
+ await this.projectsDirHandle!.removeEntry(projectName, { recursive: true });
+ } catch {
+ // Best effort cleanup
+ }
+ throw error;
+ }
+ }
+
+ /**
+ * Get or create a nested subdirectory from a path like "media/subfolder".
+ */
+ private async getOrCreateSubDir(
+ root: FileSystemDirectoryHandle,
+ path: string,
+ ): Promise {
+ const segments = path.replace(/\/$/, '').split('/').filter(Boolean);
+ let current = root;
+ for (const seg of segments) {
+ current = await current.getDirectoryHandle(seg, { create: true });
+ }
+ return current;
+ }
+
+ /**
+ * Return a unique project name by appending (1), (2), etc. if the name already exists.
+ */
+ public async resolveUniqueName(name: string): Promise {
+ this.ensureInitialized();
+
+ if (!(await this.exists(name))) return name;
+
+ let counter = 1;
+ let candidate: string;
+ do {
+ candidate = `${name} (${counter})`;
+ counter++;
+ } while (await this.exists(candidate));
+
+ return candidate;
+ }
+
+ // --- File I/O helpers ---
+
+ private async writeFile(
+ dirHandle: FileSystemDirectoryHandle,
+ fileName: string,
+ content: string,
+ ): Promise {
+ const fileHandle = await dirHandle.getFileHandle(fileName, { create: true });
+ const writable = await fileHandle.createWritable();
+ await writable.write(content);
+ await writable.close();
+ }
+
+ private async readFile(
+ dirHandle: FileSystemDirectoryHandle,
+ fileName: string,
+ ): Promise {
+ const fileHandle = await dirHandle.getFileHandle(fileName);
+ const file = await fileHandle.getFile();
+ return file.text();
+ }
+}
diff --git a/src/core/io/KGStorage.ts b/src/core/io/KGStorage.ts
deleted file mode 100644
index a5441c1..0000000
--- a/src/core/io/KGStorage.ts
+++ /dev/null
@@ -1,132 +0,0 @@
-// src/core/io/KGStorage.ts
-
-import { openDB } from 'idb'
-import type { IDBPDatabase } from 'idb'
-import { plainToInstance, instanceToPlain } from 'class-transformer'
-import { DB_CONSTANTS } from '../../constants/coreConstants'
-
-export interface StorageEntry {
- name: string
- data: Record
- lastModified: number
-}
-
-export class DuplicateEntryError extends Error {
- constructor(name: string) {
- super(`Entry "${name}" already exists`)
- this.name = 'DuplicateEntryError'
- }
-}
-
-export class KGStorage {
- private static instance: KGStorage
- private dbPromises: Map>
-
- private constructor() {
- this.dbPromises = new Map()
- }
-
- public static getInstance(): KGStorage {
- if (!KGStorage.instance) {
- KGStorage.instance = new KGStorage()
- }
- return KGStorage.instance
- }
-
- private getDB(dbName: string, _storeName: string, version: number = 1): Promise {
- const key = `${dbName}_${version}`
-
- if (!this.dbPromises.has(key)) {
- const dbPromise = openDB(dbName, version, {
- upgrade(db) {
- // Create all required object stores for this database
- const requiredStores = [
- DB_CONSTANTS.PROJECTS_STORE_NAME,
- DB_CONSTANTS.CONFIG_STORE_NAME
- ];
-
- for (const store of requiredStores) {
- if (!db.objectStoreNames.contains(store)) {
- db.createObjectStore(store, { keyPath: 'name' })
- console.log(`Created object store: ${store}`)
- }
- }
- },
- })
- this.dbPromises.set(key, dbPromise)
- }
-
- return this.dbPromises.get(key)!
- }
-
- public async save(
- dbName: string,
- storeName: string,
- name: string,
- data: T,
- overwrite: boolean = false,
- version: number = 1
- ): Promise {
- const db = await this.getDB(dbName, storeName, version)
- const existing = await db.get(storeName, name)
- if (existing && !overwrite) {
- throw new DuplicateEntryError(name)
- }
- const entry: StorageEntry = {
- name: name,
- data: instanceToPlain(data) as Record,
- lastModified: Date.now(),
- }
- await db.put(storeName, entry)
- }
-
- public async load(
- dbName: string,
- storeName: string,
- name: string,
- classType: new() => T,
- version: number = 1
- ): Promise {
- try {
- const db = await this.getDB(dbName, storeName, version)
- const entry = await db.get(storeName, name)
-
- if (!entry?.data) {
- console.log(`No data found for entry "${name}" in store "${storeName}" of database "${dbName}"`)
- return null
- }
-
- const instance = plainToInstance(classType, entry.data)
- const loadedInstance = Array.isArray(instance) ? instance[0] || null : instance
-
- if (loadedInstance && typeof (loadedInstance as { setName?: (projectName: string) => void }).setName === 'function') {
- (loadedInstance as { setName: (projectName: string) => void }).setName(name)
- }
-
- return loadedInstance
- } catch (error) {
- console.log(`Error loading entry "${name}" from store "${storeName}" of database "${dbName}":`, error)
- return null
- }
- }
-
- public async list(
- dbName: string,
- storeName: string,
- version: number = 1
- ): Promise {
- const db = await this.getDB(dbName, storeName, version)
- const all = await db.getAllKeys(storeName)
- return all as string[]
- }
-
- public async delete(
- dbName: string,
- storeName: string,
- name: string,
- version: number = 1
- ): Promise {
- const db = await this.getDB(dbName, storeName, version)
- await db.delete(storeName, name)
- }
-}
\ No newline at end of file
diff --git a/src/types/opfs.d.ts b/src/types/opfs.d.ts
new file mode 100644
index 0000000..c51e170
--- /dev/null
+++ b/src/types/opfs.d.ts
@@ -0,0 +1,9 @@
+/**
+ * Type augmentations for the Origin Private File System (OPFS) async iterable APIs.
+ * These are part of the File System Access API but not yet fully typed in TypeScript's lib.dom.d.ts.
+ */
+interface FileSystemDirectoryHandle {
+ values(): AsyncIterableIterator;
+ keys(): AsyncIterableIterator;
+ entries(): AsyncIterableIterator<[string, FileSystemDirectoryHandle | FileSystemFileHandle]>;
+}
diff --git a/src/util/projectNameUtil.test.ts b/src/util/projectNameUtil.test.ts
new file mode 100644
index 0000000..03f6329
--- /dev/null
+++ b/src/util/projectNameUtil.test.ts
@@ -0,0 +1,78 @@
+import { describe, it, expect } from 'vitest';
+import { isValidProjectName, sanitizeProjectName } from './projectNameUtil';
+
+describe('isValidProjectName', () => {
+ it('accepts simple alphanumeric names', () => {
+ expect(isValidProjectName('MyProject')).toBe(true);
+ expect(isValidProjectName('project123')).toBe(true);
+ });
+
+ it('accepts names with allowed special characters', () => {
+ expect(isValidProjectName('My Song')).toBe(true);
+ expect(isValidProjectName('song-v2')).toBe(true);
+ expect(isValidProjectName('song_final')).toBe(true);
+ expect(isValidProjectName('song.backup')).toBe(true);
+ expect(isValidProjectName('Song (v2)')).toBe(true);
+ });
+
+ it('rejects empty or whitespace-only names', () => {
+ expect(isValidProjectName('')).toBe(false);
+ expect(isValidProjectName(' ')).toBe(false);
+ });
+
+ it('rejects names starting with a dot', () => {
+ expect(isValidProjectName('.hidden')).toBe(false);
+ });
+
+ it('rejects names with disallowed characters', () => {
+ expect(isValidProjectName('my/song')).toBe(false);
+ expect(isValidProjectName('my\\song')).toBe(false);
+ expect(isValidProjectName('my:song')).toBe(false);
+ expect(isValidProjectName('my*song')).toBe(false);
+ expect(isValidProjectName('my?song')).toBe(false);
+ expect(isValidProjectName('my"song')).toBe(false);
+ expect(isValidProjectName('mysong')).toBe(false);
+ expect(isValidProjectName('my|song')).toBe(false);
+ });
+
+ it('accepts accented characters', () => {
+ expect(isValidProjectName('Café Waltz')).toBe(true);
+ expect(isValidProjectName('Ñoño')).toBe(true);
+ });
+});
+
+describe('sanitizeProjectName', () => {
+ it('returns valid names unchanged', () => {
+ expect(sanitizeProjectName('My Song')).toBe('My Song');
+ expect(sanitizeProjectName('project-123')).toBe('project-123');
+ });
+
+ it('replaces disallowed characters with underscores', () => {
+ expect(sanitizeProjectName('my/song')).toBe('my_song');
+ expect(sanitizeProjectName('my:song')).toBe('my_song');
+ expect(sanitizeProjectName('a*b?c')).toBe('a_b_c');
+ });
+
+ it('collapses consecutive underscores', () => {
+ expect(sanitizeProjectName('a///b')).toBe('a_b');
+ expect(sanitizeProjectName('a__b')).toBe('a_b');
+ });
+
+ it('collapses consecutive spaces', () => {
+ expect(sanitizeProjectName('a b')).toBe('a b');
+ });
+
+ it('trims leading/trailing whitespace, underscores, and dots', () => {
+ expect(sanitizeProjectName(' My Song ')).toBe('My Song');
+ expect(sanitizeProjectName('__song__')).toBe('song');
+ expect(sanitizeProjectName('.hidden')).toBe('hidden');
+ expect(sanitizeProjectName('...dots...')).toBe('dots');
+ });
+
+ it('returns fallback for names that become empty after sanitization', () => {
+ expect(sanitizeProjectName('///')).toBe('Untitled Project');
+ expect(sanitizeProjectName('...')).toBe('Untitled Project');
+ expect(sanitizeProjectName('___')).toBe('Untitled Project');
+ });
+});
diff --git a/src/util/projectNameUtil.ts b/src/util/projectNameUtil.ts
new file mode 100644
index 0000000..6f8739c
--- /dev/null
+++ b/src/util/projectNameUtil.ts
@@ -0,0 +1,44 @@
+/**
+ * Allowed characters for project names: letters, numbers, space, hyphen, underscore, period, parentheses.
+ * These are safe across Windows, macOS, and Linux as directory names.
+ */
+const VALID_PROJECT_NAME_REGEX = /^[a-zA-Z0-9 \-_.()\u00C0-\u024F]+$/;
+
+/**
+ * Characters that are NOT allowed in project names — replaced during sanitization.
+ */
+const DISALLOWED_CHARS_REGEX = /[^a-zA-Z0-9 \-_.()\u00C0-\u024F]/g;
+
+/**
+ * Validate whether a project name contains only allowed characters.
+ * Does NOT check for empty string — caller should check that separately.
+ */
+export function isValidProjectName(name: string): boolean {
+ if (!name || name.trim().length === 0) return false;
+ if (name.startsWith('.')) return false; // hidden files on Unix
+ return VALID_PROJECT_NAME_REGEX.test(name);
+}
+
+/**
+ * Sanitize a project name by replacing disallowed characters with underscores,
+ * collapsing consecutive underscores/spaces, and trimming.
+ */
+export function sanitizeProjectName(name: string): string {
+ let sanitized = name.replace(DISALLOWED_CHARS_REGEX, '_');
+
+ // Collapse consecutive underscores
+ sanitized = sanitized.replace(/_{2,}/g, '_');
+
+ // Collapse consecutive spaces
+ sanitized = sanitized.replace(/ {2,}/g, ' ');
+
+ // Trim leading/trailing whitespace, underscores, and dots
+ sanitized = sanitized.replace(/^[.\s_]+|[.\s_]+$/g, '').trim();
+
+ // If everything was stripped, provide a fallback
+ if (sanitized.length === 0) {
+ sanitized = 'Untitled Project';
+ }
+
+ return sanitized;
+}
diff --git a/src/util/saveUtil.ts b/src/util/saveUtil.ts
index d54acd1..780e16a 100644
--- a/src/util/saveUtil.ts
+++ b/src/util/saveUtil.ts
@@ -1,5 +1,4 @@
-import { KGStorage, DuplicateEntryError } from '../core/io/KGStorage';
-import { DB_CONSTANTS } from '../constants/coreConstants';
+import { KGProjectStorage, DuplicateEntryError } from '../core/io/KGProjectStorage';
import { KGCore } from '../core/KGCore';
/**
@@ -13,43 +12,37 @@ export const saveProject = async (
projectName: string,
setStatus: (status: string) => void
): Promise => {
- const storage = KGStorage.getInstance();
-
+ const storage = KGProjectStorage.getInstance();
+
try {
await storage.save(
- DB_CONSTANTS.DB_NAME,
- DB_CONSTANTS.PROJECTS_STORE_NAME,
projectName,
KGCore.instance().getCurrentProject(),
false,
- DB_CONSTANTS.DB_VERSION
);
-
+
setStatus(`Project "${projectName}" has been saved`);
console.log("project saved successfully");
return true;
-
+
} catch (error) {
console.error("Error saving project:", error);
-
+
if (error instanceof DuplicateEntryError) {
const confirmed = window.confirm(`Project "${projectName}" already exists. Do you want to overwrite it?`);
-
+
if (confirmed) {
try {
await storage.save(
- DB_CONSTANTS.DB_NAME,
- DB_CONSTANTS.PROJECTS_STORE_NAME,
projectName,
KGCore.instance().getCurrentProject(),
true,
- DB_CONSTANTS.DB_VERSION
);
-
+
setStatus(`Project "${projectName}" has been saved`);
console.log("project saved successfully after overwrite");
return true;
-
+
} catch (overwriteError) {
console.error("Error overwriting project:", overwriteError);
window.alert(`An error occurred while overwriting the project: ${overwriteError}`);
@@ -65,4 +58,4 @@ export const saveProject = async (
return false;
}
}
-};
\ No newline at end of file
+};