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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user