Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"axios": "^1.12.2",
"browser-update": "^3.3.63",
"dayjs": "^1.11.18",
"dexie": "^4.2.1",
"lodash": "^4.17.21",
"lodash.debounce": "^4.0.8",
"react": "^19.1.1",
Expand All @@ -58,6 +59,7 @@
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/browser-update": "^3.3.3",
"@types/dexie": "^1.3.32",
"@types/fontkit": "^2.0.8",
"@types/lodash": "^4.17.20",
"@types/lodash.debounce": "^4.0.9",
Expand Down
175 changes: 37 additions & 138 deletions src/utils/indexedDBUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,51 +5,36 @@ import type {
} from "@/types/contestData";
import configSchema from "../../typst-template/config-schema.json";
import Ajv from "ajv";

const DB_NAME = "cnoi-statement-generator";
const DB_VERSION = 1;
const CONFIG_STORE = "config";
const IMAGE_STORE = "images";
import Dexie from "dexie";

const ajv = new Ajv({ allErrors: true });
const validateSchema = ajv.compile(configSchema);

/**
* Open IndexedDB connection
* Dexie database schema
*/
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);

request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);

request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;

// Create config store
if (!db.objectStoreNames.contains(CONFIG_STORE)) {
db.createObjectStore(CONFIG_STORE);
}

// Create image store with uuid as key
if (!db.objectStoreNames.contains(IMAGE_STORE)) {
db.createObjectStore(IMAGE_STORE, { keyPath: "uuid" });
}
};
});
class CnoiDatabase extends Dexie {
// Using Table instead of EntityTable to support non-inlined keys
config!: Dexie.Table<StoredContestData, string>;
images!: Dexie.Table<EditorImageData, string>;

constructor() {
super("cnoi-statement-generator");
this.version(1).stores({
config: "", // Empty string means the key is not part of the object (out-of-line key)
images: "uuid",
});
}
}

const db = new CnoiDatabase();

/**
* Save config to IndexedDB
*/
export async function saveConfigToDB(
data: ContestDataWithImages,
): Promise<void> {
const db = await openDB();
const transaction = db.transaction(CONFIG_STORE, "readwrite");
const store = transaction.objectStore(CONFIG_STORE);

// Remove url field from images for storage
const storedData: StoredContestData = {
...data,
Expand All @@ -59,18 +44,7 @@ export async function saveConfigToDB(
})),
};

store.put(storedData, "current");

return new Promise((resolve, reject) => {
transaction.oncomplete = () => {
db.close();
resolve();
};
transaction.onerror = () => {
db.close();
reject(transaction.error);
};
});
await db.config.put(storedData, "current");
}

/**
Expand All @@ -80,124 +54,49 @@ export async function loadConfigFromDB(): Promise<{
data: StoredContestData;
images: Map<string, Blob>; // uuid -> Blob
} | null> {
const db = await openDB();
const transaction = db.transaction([CONFIG_STORE, IMAGE_STORE], "readonly");
const configStore = transaction.objectStore(CONFIG_STORE);
const imageStore = transaction.objectStore(IMAGE_STORE);

return new Promise((resolve, reject) => {
const configRequest = configStore.get("current");

configRequest.onsuccess = async () => {
const storedData = configRequest.result as StoredContestData | undefined;

if (!storedData) {
db.close();
resolve(null);
return;
}
const storedData = await db.config.get("current");

// Load all images
const imageMap = new Map<string, Blob>();
const imagePromises = (storedData.images || []).map(
(img) =>
new Promise<void>((resolveImg, rejectImg) => {
const imgRequest = imageStore.get(img.uuid);
imgRequest.onsuccess = () => {
const imageData = imgRequest.result as
| EditorImageData
| undefined;
if (imageData) {
imageMap.set(imageData.uuid, imageData.blob);
}
resolveImg();
};
imgRequest.onerror = () => rejectImg(imgRequest.error);
}),
);
if (!storedData) {
return null;
}

try {
await Promise.all(imagePromises);
db.close();
// Load all images
const imageMap = new Map<string, Blob>();
const imageUuids = (storedData.images || []).map((img) => img.uuid);

resolve({ data: storedData, images: imageMap });
} catch (error) {
db.close();
reject(error);
if (imageUuids.length > 0) {
const images = await db.images.bulkGet(imageUuids);
images.forEach((imageData) => {
if (imageData) {
imageMap.set(imageData.uuid, imageData.blob);
}
};
});
}

configRequest.onerror = () => {
db.close();
reject(configRequest.error);
};
});
return { data: storedData, images: imageMap };
}

/**
* Save image to IndexedDB
*/
export async function saveImageToDB(uuid: string, blob: Blob): Promise<void> {
const db = await openDB();
const transaction = db.transaction(IMAGE_STORE, "readwrite");
const store = transaction.objectStore(IMAGE_STORE);

const imageData: EditorImageData = { uuid, blob };
store.put(imageData);

return new Promise((resolve, reject) => {
transaction.oncomplete = () => {
db.close();
resolve();
};
transaction.onerror = () => {
db.close();
reject(transaction.error);
};
});
await db.images.put({ uuid, blob });
}

/**
* Delete image from IndexedDB
*/
export async function deleteImageFromDB(uuid: string): Promise<void> {
const db = await openDB();
const transaction = db.transaction(IMAGE_STORE, "readwrite");
const store = transaction.objectStore(IMAGE_STORE);

store.delete(uuid);

return new Promise((resolve, reject) => {
transaction.oncomplete = () => {
db.close();
resolve();
};
transaction.onerror = () => {
db.close();
reject(transaction.error);
};
});
await db.images.delete(uuid);
}

/**
* Clear all data from IndexedDB
*/
export async function clearDB(): Promise<void> {
const db = await openDB();
const transaction = db.transaction([CONFIG_STORE, IMAGE_STORE], "readwrite");

transaction.objectStore(CONFIG_STORE).clear();
transaction.objectStore(IMAGE_STORE).clear();

return new Promise((resolve, reject) => {
transaction.oncomplete = () => {
db.close();
resolve();
};
transaction.onerror = () => {
db.close();
reject(transaction.error);
};
await db.transaction("rw", [db.config, db.images], async () => {
await db.config.clear();
await db.images.clear();
});
}

Expand Down