mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Merge branch 'main' into SaaS
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Stores FileSystemDirectoryHandle instances per folder in IndexedDB.
|
||||
* Handles are structured-cloneable so they survive page refresh.
|
||||
* Permission must be re-requested each session before writing.
|
||||
*/
|
||||
|
||||
const DB_NAME = "stirling-pdf-folder-directory-handles";
|
||||
const DB_VERSION = 1;
|
||||
const STORE = "handles";
|
||||
|
||||
/** Cached singleton DB connection — avoids opening a new connection per call. */
|
||||
let cachedDB: IDBDatabase | null = null;
|
||||
let initPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function getDB(): Promise<IDBDatabase> {
|
||||
if (cachedDB) return Promise.resolve(cachedDB);
|
||||
if (initPromise) return initPromise;
|
||||
initPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
||||
req.onsuccess = () => {
|
||||
cachedDB = req.result;
|
||||
cachedDB.onclose = () => {
|
||||
cachedDB = null;
|
||||
initPromise = null;
|
||||
};
|
||||
resolve(cachedDB);
|
||||
};
|
||||
req.onerror = () => {
|
||||
initPromise = null;
|
||||
reject(req.error);
|
||||
};
|
||||
});
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
type ExtendedDirHandle = FileSystemDirectoryHandle & {
|
||||
queryPermission(opts: object): Promise<PermissionState>;
|
||||
requestPermission(opts: object): Promise<PermissionState>;
|
||||
};
|
||||
|
||||
export const folderDirectoryHandleStorage = {
|
||||
// ── Output directory handles (readwrite) ─────────────────────────────────
|
||||
|
||||
async get(folderId: string): Promise<FileSystemDirectoryHandle | null> {
|
||||
const db = await getDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = db.transaction(STORE).objectStore(STORE).get(folderId);
|
||||
req.onsuccess = () => resolve(req.result ?? null);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
},
|
||||
|
||||
async set(
|
||||
folderId: string,
|
||||
handle: FileSystemDirectoryHandle,
|
||||
): Promise<void> {
|
||||
const db = await getDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = db
|
||||
.transaction(STORE, "readwrite")
|
||||
.objectStore(STORE)
|
||||
.put(handle, folderId);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
},
|
||||
|
||||
async remove(folderId: string): Promise<void> {
|
||||
const db = await getDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = db
|
||||
.transaction(STORE, "readwrite")
|
||||
.objectStore(STORE)
|
||||
.delete(folderId);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Verifies the handle still has readwrite permission, requesting it if needed.
|
||||
* Returns true if permission is granted, false if denied/dismissed.
|
||||
*/
|
||||
async ensurePermission(handle: FileSystemDirectoryHandle): Promise<boolean> {
|
||||
const opts = { mode: "readwrite" };
|
||||
const h = handle as ExtendedDirHandle;
|
||||
if ((await h.queryPermission(opts)) === "granted") return true;
|
||||
return (await h.requestPermission(opts)) === "granted";
|
||||
},
|
||||
|
||||
// ── Input directory handles (readonly) ────────────────────────────────────
|
||||
// Stored under key "input:{folderId}" to avoid collisions with output handles.
|
||||
|
||||
async getInput(folderId: string): Promise<FileSystemDirectoryHandle | null> {
|
||||
const db = await getDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = db
|
||||
.transaction(STORE)
|
||||
.objectStore(STORE)
|
||||
.get(`input:${folderId}`);
|
||||
req.onsuccess = () => resolve(req.result ?? null);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
},
|
||||
|
||||
async setInput(
|
||||
folderId: string,
|
||||
handle: FileSystemDirectoryHandle,
|
||||
): Promise<void> {
|
||||
const db = await getDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = db
|
||||
.transaction(STORE, "readwrite")
|
||||
.objectStore(STORE)
|
||||
.put(handle, `input:${folderId}`);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
},
|
||||
|
||||
async removeInput(folderId: string): Promise<void> {
|
||||
const db = await getDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = db
|
||||
.transaction(STORE, "readwrite")
|
||||
.objectStore(STORE)
|
||||
.delete(`input:${folderId}`);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Verifies the handle still has read permission, requesting it if needed.
|
||||
* On browsers that don't support queryPermission/requestPermission (Firefox),
|
||||
* returns true optimistically — access errors will surface naturally during iteration.
|
||||
*/
|
||||
async ensureReadPermission(
|
||||
handle: FileSystemDirectoryHandle,
|
||||
): Promise<boolean> {
|
||||
const h = handle as ExtendedDirHandle;
|
||||
if (typeof h.queryPermission !== "function") return true; // Firefox — no permission API
|
||||
const opts = { mode: "read" };
|
||||
if ((await h.queryPermission(opts)) === "granted") return true;
|
||||
return (await h.requestPermission(opts)) === "granted";
|
||||
},
|
||||
|
||||
/** Write a file blob into the directory, overwriting if it exists. */
|
||||
async writeFile(
|
||||
handle: FileSystemDirectoryHandle,
|
||||
name: string,
|
||||
blob: Blob,
|
||||
): Promise<void> {
|
||||
const fileHandle = await handle.getFileHandle(name, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(blob);
|
||||
await writable.close();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Persistent retry schedule for Watched Folder automation.
|
||||
*
|
||||
* Stores pending retries in IndexedDB so they survive page close.
|
||||
* The service worker reads this store to schedule timers; the main thread
|
||||
* drains due entries on mount, on SW notification, and on visibilitychange.
|
||||
*
|
||||
* claimDue() is atomic (single readwrite IDB transaction) — safe across tabs.
|
||||
*/
|
||||
|
||||
export interface RetryEntry {
|
||||
/** `${folderId}:${fileId}` — natural composite key */
|
||||
id: string;
|
||||
folderId: string;
|
||||
fileId: string;
|
||||
/** Absolute ms timestamp when this retry should fire */
|
||||
dueAt: number;
|
||||
attempt: number;
|
||||
ownedByFolder: boolean;
|
||||
}
|
||||
|
||||
class FolderRetryScheduleStorage {
|
||||
private dbName = "stirling-pdf-retry-schedule";
|
||||
private dbVersion = 1;
|
||||
private storeName = "retries";
|
||||
private db: IDBDatabase | null = null;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
async init(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, this.dbVersion);
|
||||
request.onerror = () =>
|
||||
reject(new Error("Failed to open retry schedule database"));
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
this.db.onclose = () => {
|
||||
this.db = null;
|
||||
this.initPromise = null;
|
||||
};
|
||||
resolve();
|
||||
};
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(this.storeName)) {
|
||||
const store = db.createObjectStore(this.storeName, { keyPath: "id" });
|
||||
// Index on dueAt lets the SW and claimDue() range-scan efficiently
|
||||
store.createIndex("dueAt", "dueAt", { unique: false });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureDB(): Promise<IDBDatabase> {
|
||||
if (!this.db) {
|
||||
this.initPromise ??= this.init();
|
||||
await this.initPromise;
|
||||
}
|
||||
if (!this.db) throw new Error("Retry schedule database not initialized");
|
||||
return this.db;
|
||||
}
|
||||
|
||||
/** Upsert a retry entry. Calling again for the same file replaces the previous schedule. */
|
||||
async schedule(
|
||||
folderId: string,
|
||||
fileId: string,
|
||||
dueAt: number,
|
||||
attempt: number,
|
||||
ownedByFolder: boolean,
|
||||
): Promise<void> {
|
||||
const db = await this.ensureDB();
|
||||
const entry: RetryEntry = {
|
||||
id: `${folderId}:${fileId}`,
|
||||
folderId,
|
||||
fileId,
|
||||
dueAt,
|
||||
attempt,
|
||||
ownedByFolder,
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction([this.storeName], "readwrite");
|
||||
const request = tx.objectStore(this.storeName).put(entry);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(new Error("Failed to schedule retry"));
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove a scheduled retry (e.g. when a folder is deleted or a file is manually retried). */
|
||||
async cancel(folderId: string, fileId: string): Promise<void> {
|
||||
const db = await this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction([this.storeName], "readwrite");
|
||||
const request = tx
|
||||
.objectStore(this.storeName)
|
||||
.delete(`${folderId}:${fileId}`);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(new Error("Failed to cancel retry"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically reads and deletes all entries whose dueAt is in the past.
|
||||
* Because the delete happens inside the same readwrite transaction as the
|
||||
* read, two concurrent tabs cannot both claim the same entry.
|
||||
*/
|
||||
async claimDue(): Promise<RetryEntry[]> {
|
||||
const db = await this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const now = Date.now();
|
||||
const claimed: RetryEntry[] = [];
|
||||
const tx = db.transaction([this.storeName], "readwrite");
|
||||
const index = tx.objectStore(this.storeName).index("dueAt");
|
||||
const cursorRequest = index.openCursor(IDBKeyRange.upperBound(now));
|
||||
cursorRequest.onsuccess = () => {
|
||||
const cursor = cursorRequest.result;
|
||||
if (cursor) {
|
||||
claimed.push(cursor.value as RetryEntry);
|
||||
cursor.delete();
|
||||
cursor.continue();
|
||||
}
|
||||
};
|
||||
tx.oncomplete = () => resolve(claimed);
|
||||
tx.onerror = () => reject(new Error("Failed to claim due retries"));
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove all scheduled retries for a folder (called when the folder is deleted). */
|
||||
async clearFolder(folderId: string): Promise<void> {
|
||||
const db = await this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction([this.storeName], "readwrite");
|
||||
const store = tx.objectStore(this.storeName);
|
||||
const cursorRequest = store.openCursor();
|
||||
cursorRequest.onsuccess = () => {
|
||||
const cursor = cursorRequest.result;
|
||||
if (!cursor) return;
|
||||
const entry = cursor.value as RetryEntry;
|
||||
if (entry.folderId === folderId) cursor.delete();
|
||||
cursor.continue();
|
||||
};
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(new Error("Failed to clear folder retries"));
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns the earliest scheduled dueAt timestamp, or null if no entries exist. */
|
||||
async getEarliestDueAt(): Promise<number | null> {
|
||||
const db = await this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction([this.storeName], "readonly");
|
||||
const cursorRequest = tx
|
||||
.objectStore(this.storeName)
|
||||
.index("dueAt")
|
||||
.openCursor();
|
||||
cursorRequest.onsuccess = () =>
|
||||
resolve(
|
||||
cursorRequest.result
|
||||
? (cursorRequest.result.value as RetryEntry).dueAt
|
||||
: null,
|
||||
);
|
||||
cursorRequest.onerror = () =>
|
||||
reject(new Error("Failed to get earliest due at"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const folderRetryScheduleStorage = new FolderRetryScheduleStorage();
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Service for managing Watched Folder run state in IndexedDB
|
||||
*/
|
||||
|
||||
import { WatchedFolderRunEntry } from "@app/types/watchedFolders";
|
||||
|
||||
const FOLDER_RUN_STATE_CHANGE_EVENT = "folder-run-state-changed";
|
||||
|
||||
interface RunStateRecord {
|
||||
folderId: string;
|
||||
runs: WatchedFolderRunEntry[];
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
class FolderRunStateStorage {
|
||||
private dbName = "stirling-pdf-folder-run-state";
|
||||
private dbVersion = 1;
|
||||
private storeName = "runStates";
|
||||
private db: IDBDatabase | null = null;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
async init(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, this.dbVersion);
|
||||
request.onerror = () =>
|
||||
reject(new Error("Failed to open folder run state database"));
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
this.db.onclose = () => {
|
||||
this.db = null;
|
||||
this.initPromise = null;
|
||||
};
|
||||
resolve();
|
||||
};
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(this.storeName)) {
|
||||
db.createObjectStore(this.storeName, { keyPath: "folderId" });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureDB(): Promise<IDBDatabase> {
|
||||
if (!this.db) {
|
||||
this.initPromise ??= this.init();
|
||||
await this.initPromise;
|
||||
}
|
||||
if (!this.db) {
|
||||
throw new Error("Folder run state database not initialized");
|
||||
}
|
||||
return this.db;
|
||||
}
|
||||
|
||||
async getFolderRunState(folderId: string): Promise<WatchedFolderRunEntry[]> {
|
||||
const db = await this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.get(folderId);
|
||||
request.onsuccess = () => {
|
||||
const record: RunStateRecord | undefined = request.result;
|
||||
resolve(record?.runs || []);
|
||||
};
|
||||
request.onerror = () =>
|
||||
reject(new Error("Failed to get folder run state"));
|
||||
});
|
||||
}
|
||||
|
||||
async setFolderRunState(
|
||||
folderId: string,
|
||||
runs: WatchedFolderRunEntry[],
|
||||
): Promise<void> {
|
||||
const db = await this.ensureDB();
|
||||
const record: RunStateRecord = { folderId, runs, lastUpdated: Date.now() };
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.put(record);
|
||||
request.onsuccess = () => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(FOLDER_RUN_STATE_CHANGE_EVENT, {
|
||||
detail: { folderId },
|
||||
}),
|
||||
);
|
||||
resolve();
|
||||
};
|
||||
request.onerror = () =>
|
||||
reject(new Error("Failed to set folder run state"));
|
||||
});
|
||||
}
|
||||
|
||||
onRunStateChange(listener: (folderId: string) => void): () => void {
|
||||
const handler = (e: Event) => listener((e as CustomEvent).detail.folderId);
|
||||
window.addEventListener(FOLDER_RUN_STATE_CHANGE_EVENT, handler);
|
||||
return () =>
|
||||
window.removeEventListener(FOLDER_RUN_STATE_CHANGE_EVENT, handler);
|
||||
}
|
||||
|
||||
/** Atomically appends entries to a folder's run state within a single readwrite transaction,
|
||||
* preventing lost-update races when multiple files are processed concurrently. */
|
||||
async appendRunEntries(
|
||||
folderId: string,
|
||||
entries: WatchedFolderRunEntry[],
|
||||
): Promise<void> {
|
||||
if (entries.length === 0) return;
|
||||
const db = await this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const getRequest = store.get(folderId);
|
||||
getRequest.onsuccess = () => {
|
||||
const existing: RunStateRecord | undefined = getRequest.result;
|
||||
const MAX_RUN_ENTRIES = 500;
|
||||
const combined = [...(existing?.runs ?? []), ...entries];
|
||||
const record: RunStateRecord = {
|
||||
folderId,
|
||||
runs:
|
||||
combined.length > MAX_RUN_ENTRIES
|
||||
? combined.slice(-MAX_RUN_ENTRIES)
|
||||
: combined,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
const putRequest = store.put(record);
|
||||
putRequest.onsuccess = () => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(FOLDER_RUN_STATE_CHANGE_EVENT, {
|
||||
detail: { folderId },
|
||||
}),
|
||||
);
|
||||
resolve();
|
||||
};
|
||||
putRequest.onerror = () =>
|
||||
reject(new Error("Failed to append run entries"));
|
||||
};
|
||||
getRequest.onerror = () =>
|
||||
reject(new Error("Failed to read run state for append"));
|
||||
});
|
||||
}
|
||||
|
||||
async clearFolderRunState(folderId: string): Promise<void> {
|
||||
const db = await this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.delete(folderId);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () =>
|
||||
reject(new Error("Failed to clear folder run state"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const folderRunStateStorage = new FolderRunStateStorage();
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Tracks which local-folder input files have already been submitted for processing.
|
||||
* Key: `{folderId}|{filename}|{size}|{lastModified}` — uniquely identifies a file version.
|
||||
* Prevents re-submitting the same file on every poll cycle.
|
||||
*/
|
||||
|
||||
const DB_NAME = "stirling-pdf-folder-seen-files";
|
||||
const DB_VERSION = 1;
|
||||
const STORE = "seenFiles";
|
||||
|
||||
/** Cached singleton DB connection — avoids opening a new connection per call. */
|
||||
let cachedDB: IDBDatabase | null = null;
|
||||
let initPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function getDB(): Promise<IDBDatabase> {
|
||||
if (cachedDB) return Promise.resolve(cachedDB);
|
||||
if (initPromise) return initPromise;
|
||||
initPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
||||
req.onsuccess = () => {
|
||||
cachedDB = req.result;
|
||||
cachedDB.onclose = () => {
|
||||
cachedDB = null;
|
||||
initPromise = null;
|
||||
};
|
||||
resolve(cachedDB);
|
||||
};
|
||||
req.onerror = () => {
|
||||
initPromise = null;
|
||||
reject(req.error);
|
||||
};
|
||||
});
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
export function makeSeenKey(folderId: string, file: File): string {
|
||||
return `${folderId}|${file.name}|${file.size}|${file.lastModified}`;
|
||||
}
|
||||
|
||||
export const folderSeenFilesStorage = {
|
||||
async isSeen(key: string): Promise<boolean> {
|
||||
const db = await getDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = db.transaction(STORE).objectStore(STORE).get(key);
|
||||
req.onsuccess = () => resolve(req.result != null);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
},
|
||||
|
||||
async markSeen(key: string): Promise<void> {
|
||||
const db = await getDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = db
|
||||
.transaction(STORE, "readwrite")
|
||||
.objectStore(STORE)
|
||||
.put(Date.now(), key);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
},
|
||||
|
||||
/** Remove all seen-file entries for a folder (called when folder is deleted or reset). */
|
||||
async clearFolder(folderId: string): Promise<void> {
|
||||
const db = await getDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, "readwrite");
|
||||
const store = tx.objectStore(STORE);
|
||||
const prefix = `${folderId}|`;
|
||||
const range = IDBKeyRange.bound(prefix, prefix + "");
|
||||
const req = store.openCursor(range);
|
||||
req.onsuccess = () => {
|
||||
const cursor = req.result;
|
||||
if (!cursor) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
cursor.delete();
|
||||
cursor.continue();
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user