export type Project = { id: string; name: string; createdAt: number; // ms timestamp updatedAt: number; // ms timestamp }; export type StructureNode = { key: string; type: "group" | "entry"; children?: StructureNode[]; }; export type ProjectStructure = { projectId: string; root: StructureNode; }; export type ProjectLanguageTranslations = { id: string; // `${projectId}:${language}` projectId: string; language: string; // e.g. en, zh-CN, ja values: Record; // path -> value }; const DB_NAME = "i18n-translate-it"; const DB_VERSION = 2; const STORE_PROJECTS = "projects"; const STORE_STRUCTURES = "structures"; // keyPath: projectId const STORE_TRANSLATIONS = "translations"; // keyPath: id, index by projectId function openDb(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(STORE_PROJECTS)) { db.createObjectStore(STORE_PROJECTS, { keyPath: "id" }); } if (!db.objectStoreNames.contains(STORE_STRUCTURES)) { db.createObjectStore(STORE_STRUCTURES, { keyPath: "projectId" }); } if (!db.objectStoreNames.contains(STORE_TRANSLATIONS)) { const store = db.createObjectStore(STORE_TRANSLATIONS, { keyPath: "id" }); store.createIndex("byProject", "projectId", { unique: false }); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); request.onblocked = () => { reject(new Error("IndexedDB blocked: please close other tabs.")); }; }); } function promisifyRequest(request: IDBRequest): Promise { return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } function generateId(length = 12): string { const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; const bytes = new Uint8Array(length); if (globalThis.crypto && globalThis.crypto.getRandomValues) { globalThis.crypto.getRandomValues(bytes); } else { for (let i = 0; i < length; i += 1) bytes[i] = Math.floor(Math.random() * 256); } let id = ""; for (let i = 0; i < length; i += 1) id += alphabet[bytes[i] % alphabet.length]; return id; } export async function listProjects(): Promise { const db = await openDb(); try { const tx = db.transaction(STORE_PROJECTS, "readonly"); const store = tx.objectStore(STORE_PROJECTS); const getAllReq = store.getAll(); const result = await promisifyRequest(getAllReq); await new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); // sort by createdAt desc by default when listing return (result ?? []).sort((a, b) => b.createdAt - a.createdAt); } finally { db.close(); } } export async function getProject(id: string): Promise { const db = await openDb(); try { const tx = db.transaction(STORE_PROJECTS, "readonly"); const store = tx.objectStore(STORE_PROJECTS); const req = store.get(id); const result = await promisifyRequest( req as IDBRequest ); await new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); return result; } finally { db.close(); } } export async function createProject(name: string): Promise { const trimmed = name.trim(); if (!trimmed) throw new Error("项目名称不能为空"); const project: Project = { id: generateId(12), name: trimmed, createdAt: Date.now(), updatedAt: Date.now(), }; const db = await openDb(); try { const tx = db.transaction(STORE_PROJECTS, "readwrite"); const store = tx.objectStore(STORE_PROJECTS); const putReq = store.add(project); await promisifyRequest(putReq); await new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); return project; } finally { db.close(); } } export async function deleteProject(id: string): Promise { const db = await openDb(); try { const tx = db.transaction(STORE_PROJECTS, "readwrite"); const store = tx.objectStore(STORE_PROJECTS); const delReq = store.delete(id); await promisifyRequest(delReq); await new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); } finally { db.close(); } } // ----- Structures ----- export async function getStructure(projectId: string): Promise { const db = await openDb(); try { const tx = db.transaction(STORE_STRUCTURES, "readonly"); const store = tx.objectStore(STORE_STRUCTURES); const req = store.get(projectId); const result = await promisifyRequest(req as IDBRequest); await new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); return result; } finally { db.close(); } } export async function upsertStructure(structure: ProjectStructure): Promise { const db = await openDb(); try { const tx = db.transaction(STORE_STRUCTURES, "readwrite"); const store = tx.objectStore(STORE_STRUCTURES); const putReq = store.put(structure); await promisifyRequest(putReq); await new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); } finally { db.close(); } } // ----- Translations ----- export async function upsertLanguageTranslations(projectId: string, language: string, values: Record): Promise { const id = `${projectId}:${language}`; const record: ProjectLanguageTranslations = { id, projectId, language, values }; const db = await openDb(); try { const tx = db.transaction(STORE_TRANSLATIONS, "readwrite"); const store = tx.objectStore(STORE_TRANSLATIONS); const putReq = store.put(record); await promisifyRequest(putReq); await new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); } finally { db.close(); } } export async function getLanguageTranslations(projectId: string, language: string): Promise { const id = `${projectId}:${language}`; const db = await openDb(); try { const tx = db.transaction(STORE_TRANSLATIONS, "readonly"); const store = tx.objectStore(STORE_TRANSLATIONS); const req = store.get(id); const result = await promisifyRequest(req as IDBRequest); await new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); return result; } finally { db.close(); } } export async function listLanguages(projectId: string): Promise { const db = await openDb(); try { const tx = db.transaction(STORE_TRANSLATIONS, "readonly"); const store = tx.objectStore(STORE_TRANSLATIONS); const index = store.index("byProject"); const allReq = index.getAll(IDBKeyRange.only(projectId)); const records = await promisifyRequest(allReq as IDBRequest); const langs = new Set(); for (const r of records) langs.add(r.language); return Array.from(langs); } finally { db.close(); } } export async function getAllLanguageTranslations(projectId: string): Promise { const db = await openDb(); try { const tx = db.transaction(STORE_TRANSLATIONS, "readonly"); const store = tx.objectStore(STORE_TRANSLATIONS); const index = store.index("byProject"); const allReq = index.getAll(IDBKeyRange.only(projectId)); const records = await promisifyRequest(allReq as IDBRequest); await new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); return records; } finally { db.close(); } } export async function deleteEntryFromAllLanguages(projectId: string, path: string): Promise { const db = await openDb(); try { const tx = db.transaction(STORE_TRANSLATIONS, "readwrite"); const store = tx.objectStore(STORE_TRANSLATIONS); const index = store.index("byProject"); const allReq = index.getAll(IDBKeyRange.only(projectId)); const records = await promisifyRequest(allReq as IDBRequest); for (const rec of records) { if (rec.values && Object.prototype.hasOwnProperty.call(rec.values, path)) { const { [path]: _, ...rest } = rec.values; rec.values = rest; await promisifyRequest(store.put(rec)); } } await new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); } finally { db.close(); } } export async function renameEntryInAllLanguages(projectId: string, oldPath: string, newPath: string): Promise { const db = await openDb(); try { const tx = db.transaction(STORE_TRANSLATIONS, "readwrite"); const store = tx.objectStore(STORE_TRANSLATIONS); const index = store.index("byProject"); const allReq = index.getAll(IDBKeyRange.only(projectId)); const records = await promisifyRequest(allReq as IDBRequest); for (const rec of records) { const values = rec.values ?? {}; if (Object.prototype.hasOwnProperty.call(values, oldPath)) { const val = values[oldPath]; const updated = { ...values }; delete updated[oldPath]; updated[newPath] = val; rec.values = updated; await promisifyRequest(store.put(rec)); } } await new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); } finally { db.close(); } }