// @ts-check /** * Notebooks kept in the browser, for Kokkoja opened from a web site with * no local program on the computer. * * The notebooks follow the same rules as the directories the local program * keeps (`notebook.js`): a notebook holds `Current.svg`, the page being * written, and dated kept pages such as `2026_10_02_01.svg`. They live in * the browser's IndexedDB for this site, which survives closing the * browser but belongs to this browser on this computer alone; the way to * take a page out is to download it. The identity is kept in the * browser's localStorage in the same way. */ const DATABASE = 'kokkoja'; const PAGES = 'pages'; const IDENTITY_KEY = 'kokkoja_identity'; const CURRENT_PAGE = 'Current.svg'; const KEPT_PAGE = /^(\d{4}_\d{2}_\d{2})_(\d{2,})\.svg$/; /** * @typedef {object} StoredPage * @property {string} notebook * @property {string} name * @property {string} text * @property {number} changed When the page was last written, in milliseconds. */ /** @type {Promise | null} */ let opening = null; /** @returns {Promise} */ function database() { opening ??= new Promise((resolve, reject) => { const request = indexedDB.open(DATABASE, 1); request.onupgradeneeded = () => { const store = request.result.createObjectStore(PAGES, { keyPath: ['notebook', 'name'] }); store.createIndex('notebook', 'notebook'); }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error ?? new Error('The browser’s storage cannot be opened.')); }); return opening; } /** * @template T * @param {IDBRequest} request * @returns {Promise} */ function result(request) { return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error ?? new Error('The browser’s storage failed.')); }); } /** * Run work in one transaction on the pages. * @template T * @param {IDBTransactionMode} mode * @param {(pages: IDBObjectStore) => Promise} work * @returns {Promise} */ async function transaction(mode, work) { const db = await database(); const tx = db.transaction(PAGES, mode); const done = new Promise((resolve, reject) => { tx.oncomplete = () => resolve(undefined); tx.onerror = () => reject(tx.error ?? new Error('The browser’s storage failed.')); tx.onabort = () => reject(tx.error ?? new Error('The browser’s storage failed.')); }); const value = await work(tx.objectStore(PAGES)); await done; return value; } /** * @param {IDBObjectStore} pages * @param {string} notebook * @returns {Promise} The notebook's pages, by name. */ async function pagesOf(pages, notebook) { const found = /** @type {StoredPage[]} */ (await result(pages.index('notebook').getAll(notebook))); return found.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); } /** * @param {number} time Milliseconds. * @returns {string} The day, as 2026_10_02. */ function dayOf(time) { const date = new Date(time); return [date.getFullYear(), date.getMonth() + 1, date.getDate()] .map((part) => String(part).padStart(2, '0')).join('_'); } /** @type {import('./notebook.js').Backend} */ export const backend = { async readIdentity() { return localStorage.getItem(IDENTITY_KEY); }, async keepIdentity(text) { if (localStorage.getItem(IDENTITY_KEY) !== null) return false; localStorage.setItem(IDENTITY_KEY, text); return true; }, async listNotebooks() { const names = await transaction('readonly', async (pages) => { const keys = /** @type {[string, string][]} */ (await result(pages.getAllKeys())); return [...new Set(keys.map(([notebook]) => notebook))].sort(); }); return { directory: '', notebooks: names }; }, async listPages(notebook) { return transaction('readonly', async (pages) => (await pagesOf(pages, notebook)).map((page) => page.name)); }, async readPage(notebook, pageName) { const page = /** @type {StoredPage | undefined} */ (await transaction( 'readonly', (pages) => result(pages.get([notebook, pageName])))); return page ? page.text : null; }, async writePage(notebook, pageName, text) { await transaction('readwrite', async (pages) => { await result(pages.put({ notebook, name: pageName, text, changed: Date.now() })); }); }, // The same rules as keep_page() in kokkoja.py: nothing is kept of a // blank page or of one already kept; the name is the day the page was // last changed and a serial number. async keepPage(notebook) { return transaction('readwrite', async (pages) => { const current = /** @type {StoredPage | undefined} */ (await result(pages.get([notebook, CURRENT_PAGE]))); const blank = !['data-stroke=', 'data-text=', ' current?.text.includes(mark)); if (!current || blank) return { kept: null, new: false }; const kept = (await pagesOf(pages, notebook)).filter((page) => KEPT_PAGE.test(page.name)); const same = kept.find((page) => page.text === current.text); if (same) return { kept: same.name, new: false }; const day = dayOf(current.changed); const serials = kept.map((page) => /** @type {RegExpExecArray} */ (KEPT_PAGE.exec(page.name))) .filter((match) => match[1] === day).map((match) => Number(match[2])); const name = `${day}_${String(Math.max(0, ...serials) + 1).padStart(2, '0')}.svg`; await result(pages.put({ notebook, name, text: current.text, changed: current.changed })); return { kept: name, new: true }; }); }, };