Search / static /reader-store.js
OpenCode
Finalize Reader navigation and current cache contracts
4754d8a
Raw History Blame Contribute Delete
8.93 kB
(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);