refactor: migrate project storage from IndexedDB to OPFS with folder-based structure
- 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
This commit is contained in:
+2
-1
@@ -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'],
|
||||
}
|
||||
},
|
||||
])
|
||||
|
||||
Generated
+42
-10
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
+22
@@ -141,6 +141,9 @@ function App() {
|
||||
|
||||
{/* Global Loading Overlay for instrument buffer loading */}
|
||||
<GlobalLoadingOverlayContainer />
|
||||
|
||||
{/* Migration Loading Overlay */}
|
||||
<MigrationOverlayContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<boolean>(() => KGCore.instance().getIsMigrating());
|
||||
|
||||
useEffectReact(() => {
|
||||
KGCore.instance().setMigrationStateChangeCallback(setIsMigrating);
|
||||
return () => {
|
||||
KGCore.instance().setMigrationStateChangeCallback(() => {});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LoadingOverlay
|
||||
visible={isMigrating}
|
||||
message="Migrating projects to new storage..."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+76
-48
@@ -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();
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
// Bundle the project folder into a zip
|
||||
const blob = await storage.exportAsZip(projectName);
|
||||
|
||||
// Create a downloadable blob
|
||||
const blob = new Blob([projectData], { type: 'application/json' });
|
||||
|
||||
// Create a temporary download link
|
||||
// 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,30 +305,62 @@ 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)
|
||||
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}"`);
|
||||
// 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 successfully:", deserializedProject);
|
||||
console.log("KGStudio JSON project imported and saved to OPFS:", importedName);
|
||||
}
|
||||
|
||||
} catch (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"
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -72,6 +76,18 @@ export class KGCore {
|
||||
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();
|
||||
@@ -194,6 +210,22 @@ export class KGCore {
|
||||
this.selectionChangeCallbacks.forEach(callback => callback());
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
+215
-1
@@ -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 <path> — change directory (supports .., /, relative, and quoted paths)
|
||||
* cat <file> — print file contents
|
||||
* dl <file> — 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<void> {
|
||||
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 <path>, cat <file>, dl <file>')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`opfs: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
private async opfsResolveCwd(): Promise<FileSystemDirectoryHandle> {
|
||||
let dir = await navigator.storage.getDirectory()
|
||||
for (const segment of this.opfsCwd) {
|
||||
dir = await dir.getDirectoryHandle(segment)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
private async opfsLs(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<number> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
await storage.saveRaw(CONFIG_UPGRADER_CONSTANTS.VERSION_KEY, {
|
||||
version,
|
||||
upgradedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>; lastModified: number }> = [];
|
||||
|
||||
vi.mock('idb', () => {
|
||||
const stores: Record<string, Map<string, unknown>> = {};
|
||||
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<string, MockDirHandle | MockFileHandle>();
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<string, unknown>;
|
||||
lastModified: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all projects from the old IndexedDB store directly (no KGStorage dependency).
|
||||
*/
|
||||
async function readAllProjectsFromIndexedDB(): Promise<OldProjectEntry[]> {
|
||||
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<string, unknown>,
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock idb before importing KGConfigStorage
|
||||
vi.mock('idb', () => {
|
||||
const stores: Record<string, Map<string, unknown>> = {};
|
||||
|
||||
const getStore = (name: string): Map<string, unknown> => {
|
||||
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<string, Map<string, unknown>>;
|
||||
__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<string, unknown>).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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
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<IDBPDatabase> | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): KGConfigStorage {
|
||||
if (!KGConfigStorage._instance) {
|
||||
KGConfigStorage._instance = new KGConfigStorage();
|
||||
}
|
||||
return KGConfigStorage._instance;
|
||||
}
|
||||
|
||||
private getDB(): Promise<IDBPDatabase> {
|
||||
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<void> {
|
||||
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<string, unknown>,
|
||||
lastModified: Date.now(),
|
||||
};
|
||||
await db.put(storeName, entry);
|
||||
}
|
||||
|
||||
public async load<T>(name: string, classType: new () => T): Promise<T | null> {
|
||||
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<void> {
|
||||
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<Record<string, unknown> | 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<string, unknown>): Promise<void> {
|
||||
const db = await this.getDB();
|
||||
const entry: ConfigStorageEntry = {
|
||||
name,
|
||||
data,
|
||||
lastModified: Date.now(),
|
||||
};
|
||||
await db.put(DB_CONSTANTS.CONFIG_STORE_NAME, entry);
|
||||
}
|
||||
}
|
||||
@@ -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<string, MockFileSystemDirectoryHandle | MockFileSystemFileHandle>();
|
||||
|
||||
constructor(public name: string) {}
|
||||
|
||||
async getDirectoryHandle(name: string, options?: { create?: boolean }): Promise<MockFileSystemDirectoryHandle> {
|
||||
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<MockFileSystemFileHandle> {
|
||||
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<void> {
|
||||
if (!this.entries.has(name)) {
|
||||
throw new DOMException(`Entry "${name}" not found`, 'NotFoundError');
|
||||
}
|
||||
this.entries.delete(name);
|
||||
}
|
||||
|
||||
async *values(): AsyncIterableIterator<MockFileSystemDirectoryHandle | MockFileSystemFileHandle> {
|
||||
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<string, unknown> }).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');
|
||||
});
|
||||
});
|
||||
@@ -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/<ProjectName>/meta.json
|
||||
* projects/<ProjectName>/project.json
|
||||
* projects/<ProjectName>/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<void> {
|
||||
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<void> {
|
||||
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<string, unknown>;
|
||||
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<KGProject | null> {
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<Blob> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<FileSystemDirectoryHandle> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
const fileHandle = await dirHandle.getFileHandle(fileName);
|
||||
const file = await fileHandle.getFile();
|
||||
return file.text();
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
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<string, Promise<IDBPDatabase>>
|
||||
|
||||
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<IDBPDatabase> {
|
||||
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<T>(
|
||||
dbName: string,
|
||||
storeName: string,
|
||||
name: string,
|
||||
data: T,
|
||||
overwrite: boolean = false,
|
||||
version: number = 1
|
||||
): Promise<void> {
|
||||
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<string, unknown>,
|
||||
lastModified: Date.now(),
|
||||
}
|
||||
await db.put(storeName, entry)
|
||||
}
|
||||
|
||||
public async load<T>(
|
||||
dbName: string,
|
||||
storeName: string,
|
||||
name: string,
|
||||
classType: new() => T,
|
||||
version: number = 1
|
||||
): Promise<T | null> {
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
const db = await this.getDB(dbName, storeName, version)
|
||||
await db.delete(storeName, name)
|
||||
}
|
||||
}
|
||||
Vendored
+9
@@ -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<FileSystemDirectoryHandle | FileSystemFileHandle>;
|
||||
keys(): AsyncIterableIterator<string>;
|
||||
entries(): AsyncIterableIterator<[string, FileSystemDirectoryHandle | FileSystemFileHandle]>;
|
||||
}
|
||||
@@ -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('my<song')).toBe(false);
|
||||
expect(isValidProjectName('my>song')).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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,16 +12,13 @@ export const saveProject = async (
|
||||
projectName: string,
|
||||
setStatus: (status: string) => void
|
||||
): Promise<boolean> => {
|
||||
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`);
|
||||
@@ -38,12 +34,9 @@ export const saveProject = async (
|
||||
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`);
|
||||
|
||||
Reference in New Issue
Block a user