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:
Xiaohan-Tian
2026-04-09 19:25:08 -07:00
parent 17610f12f3
commit 2d3ea33ce6
21 changed files with 1781 additions and 235 deletions
+2 -1
View File
@@ -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'],
}
},
])
+42 -10
View File
@@ -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": {
+1
View File
@@ -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
View File
@@ -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..."
/>
);
};
+89 -61
View File
@@ -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"
/>
+12
View File
@@ -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',
};
+35 -3
View File
@@ -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;
}
+215 -1
View File
@@ -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
}
}
+3 -10
View File
@@ -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) {
+86
View File
@@ -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);
});
});
+119
View File
@@ -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);
}
}
+230
View File
@@ -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');
});
});
+398
View File
@@ -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();
}
}
-132
View File
@@ -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)
}
}
+9
View File
@@ -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]>;
}
+78
View File
@@ -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');
});
});
+44
View File
@@ -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;
}
+10 -17
View File
@@ -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<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`);
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;
}
}
};
};