I18n-Translate-It/src/lib/db.ts

326 lines
11 KiB
TypeScript

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<string, string>; // 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<IDBDatabase> {
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<T>(request: IDBRequest<T>): Promise<T> {
return new Promise<T>((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<Project[]> {
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<Project[]>(getAllReq);
await new Promise<void>((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<Project | undefined> {
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<Project | undefined>(
req as IDBRequest<Project | undefined>
);
await new Promise<void>((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<Project> {
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<void>((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<void> {
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<void>((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<ProjectStructure | undefined> {
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<ProjectStructure | undefined>(req as IDBRequest<ProjectStructure | undefined>);
await new Promise<void>((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<void> {
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<void>((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<string, string>): Promise<void> {
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<void>((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<ProjectLanguageTranslations | undefined> {
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<ProjectLanguageTranslations | undefined>(req as IDBRequest<ProjectLanguageTranslations | undefined>);
await new Promise<void>((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<string[]> {
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<ProjectLanguageTranslations[]>(allReq as IDBRequest<ProjectLanguageTranslations[]>);
const langs = new Set<string>();
for (const r of records) langs.add(r.language);
return Array.from(langs);
} finally {
db.close();
}
}
export async function getAllLanguageTranslations(projectId: string): Promise<ProjectLanguageTranslations[]> {
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<ProjectLanguageTranslations[]>(allReq as IDBRequest<ProjectLanguageTranslations[]>);
await new Promise<void>((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<void> {
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<ProjectLanguageTranslations[]>(allReq as IDBRequest<ProjectLanguageTranslations[]>);
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<void>((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<void> {
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<ProjectLanguageTranslations[]>(allReq as IDBRequest<ProjectLanguageTranslations[]>);
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<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
} finally {
db.close();
}
}