326 lines
11 KiB
TypeScript
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();
|
|
}
|
|
}
|