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 +};