(function (root) { "use strict"; const DB_NAME = "voiceofml-reader"; const STORE_NAME = "entries"; const BOOKMARK_STORE_NAME = "bookmarks"; const MAX_ENTRIES = 200; const SCHEMA_VERSION = 1; const DB_VERSION = 3; let databasePromise = null; const subscribers = new Set(); const storeChannel = typeof root.BroadcastChannel === "function" ? new root.BroadcastChannel("voiceofml-reader-store-v1") : null; let disposed = false; if (storeChannel) storeChannel.onmessage = (event) => { if (disposed) return; for (const listener of subscribers) listener({ ...event.data, remote: true }); }; function notify(change) { if (disposed) return; try { storeChannel?.postMessage(change); } catch (_) {} for (const listener of subscribers) listener({ ...change, remote: false }); } function subscribe(listener) { if (typeof listener !== "function") throw new TypeError("Reader store subscriber must be a function"); if (disposed) return () => {}; subscribers.add(listener); return () => subscribers.delete(listener); } function dispose() { if (disposed) return; disposed = true; subscribers.clear(); try { storeChannel?.close?.(); } catch (_) {} } function openDatabase() { if (databasePromise) return databasePromise; const opening = new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = () => { const database = request.result; for (const name of Array.from(database.objectStoreNames)) database.deleteObjectStore(name); const store = database.createObjectStore(STORE_NAME, { keyPath: "url" }); store.createIndex("lastReadAt", "lastReadAt"); const bookmarks = database.createObjectStore(BOOKMARK_STORE_NAME, { keyPath: "id" }); bookmarks.createIndex("url", "url"); bookmarks.createIndex("createdAt", "createdAt"); }; request.onsuccess = () => { const database = request.result; database.onversionchange = () => { database.close(); if (databasePromise === opening) databasePromise = null; }; if (databasePromise !== opening) { database.close(); return; } resolve(database); }; request.onblocked = () => { if (databasePromise === opening) databasePromise = null; reject(new Error("Reader database upgrade was blocked by another tab")); }; request.onerror = () => { if (databasePromise === opening) databasePromise = null; reject(request.error); }; }); databasePromise = opening; return databasePromise; } async function transaction(mode, callback) { const database = await openDatabase(); return new Promise((resolve, reject) => { const tx = database.transaction(STORE_NAME, mode); const result = callback(tx.objectStore(STORE_NAME)); tx.oncomplete = () => resolve(result); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); } function normalizeHistoryEntry(entry) { if (!entry || typeof entry.url !== "string" || !entry.url) return null; return { ...entry, schemaVersion: SCHEMA_VERSION, title: String(entry.title || ""), extension: String(entry.extension || ""), lastReadAt: Number.isFinite(Number(entry.lastReadAt)) ? Number(entry.lastReadAt) : Date.now() }; } function normalizeBookmarkEntry(entry) { if ( !entry || typeof entry.id !== "string" || !entry.id || typeof entry.url !== "string" || !entry.url ) return null; return { ...entry, schemaVersion: SCHEMA_VERSION, label: String(entry.label || "未命名书签"), createdAt: Number.isFinite(Number(entry.createdAt)) ? Number(entry.createdAt) : Date.now() }; } async function get(url) { const database = await openDatabase(); return new Promise((resolve, reject) => { const tx = database.transaction(STORE_NAME, "readwrite"), store = tx.objectStore(STORE_NAME); const request = store.get(url); request.onsuccess = () => { const raw = request.result || null, entry = raw?.schemaVersion === SCHEMA_VERSION ? normalizeHistoryEntry(raw) : null; if (raw && !entry) { store.delete(url); resolve(null); } else { resolve(entry); } }; request.onerror = () => reject(request.error); }); } async function put(entry) { entry = normalizeHistoryEntry(entry); if (!entry) throw new TypeError("Invalid reader history entry"); const database = await openDatabase(); await new Promise((resolve, reject) => { const tx = database.transaction(STORE_NAME, "readwrite"); const store = tx.objectStore(STORE_NAME); const existing = store.get(entry.url); existing.onsuccess = () => { if (existing.result) { if (Number(existing.result.lastReadAt) > Number(entry.lastReadAt)) return; store.put(entry); return; } const count = store.count(); count.onsuccess = () => { if (count.result < MAX_ENTRIES) { store.put(entry); return; } const oldest = store.index("lastReadAt").openCursor(); oldest.onsuccess = () => { if (oldest.result) oldest.result.delete(); store.put(entry); }; }; }; tx.oncomplete = () => { notify({ type: "history", url: entry.url, lastReadAt: entry.lastReadAt }); resolve(); }; tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); } async function listEntries( storeName, indexName, normalize, { query = null, direction = "next", limit = Infinity } = {} ) { const database = await openDatabase(); return new Promise((resolve, reject) => { const entries = []; const tx = database.transaction(storeName, "readwrite"), store = tx.objectStore(storeName); const request = store.index(indexName).openCursor(query, direction); request.onsuccess = () => { const cursor = request.result; if (!cursor || entries.length >= limit) return resolve(entries); const entry = cursor.value.schemaVersion === SCHEMA_VERSION ? normalize(cursor.value) : null; if (entry) { entries.push(entry); } else cursor.delete(); cursor.continue(); }; request.onerror = () => reject(request.error); }); } function list(limit = MAX_ENTRIES) { return listEntries(STORE_NAME, "lastReadAt", normalizeHistoryEntry, { direction: "prev", limit }); } async function remove(url) { await transaction("readwrite", (store) => store.delete(url)); notify({ type: "history-remove", url }); } async function clearHistory() { await transaction("readwrite", (store) => store.clear()); notify({ type: "history-clear" }); } async function putBookmark(entry) { entry = normalizeBookmarkEntry(entry); if (!entry) throw new TypeError("Invalid reader bookmark entry"); const database = await openDatabase(); await new Promise((resolve, reject) => { const tx = database.transaction(BOOKMARK_STORE_NAME, "readwrite"); tx.objectStore(BOOKMARK_STORE_NAME).put(entry); tx.oncomplete = () => { notify({ type: "bookmark", url: entry.url, id: entry.id }); resolve(); }; tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); } async function listBookmarks(url) { const entries = await listEntries(BOOKMARK_STORE_NAME, "url", normalizeBookmarkEntry, { query: IDBKeyRange.only(url) }); return entries.sort((a, b) => b.createdAt - a.createdAt); } function listAllBookmarks() { return listEntries(BOOKMARK_STORE_NAME, "createdAt", normalizeBookmarkEntry, { direction: "prev" }); } async function removeBookmark(id) { const database = await openDatabase(); await new Promise((resolve, reject) => { const tx = database.transaction(BOOKMARK_STORE_NAME, "readwrite"); tx.objectStore(BOOKMARK_STORE_NAME).delete(id); tx.oncomplete = () => { notify({ type: "bookmark-remove", id }); resolve(); }; tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); } root.VoiceOfMLReaderStore = Object.freeze({ DB_NAME, DB_VERSION, MAX_ENTRIES, SCHEMA_VERSION, get, put, list, remove, clearHistory, putBookmark, listBookmarks, listAllBookmarks, removeBookmark, subscribe, dispose }); })(typeof self !== "undefined" ? self : window);