// @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$/; /** * What names a page file rather than describes the page: the session, * the host, the page's id and number on the root element, and the title. * A page loaded from a notebook is a new page with new ones of these, so * two files of the same page differ here and nowhere else. They stand * before the paper's , and only there; typed text may contain * anything. The same rule as page_content() in kokkoja.py. */ const VOLATILE = / data-(?:session|host|page|number)="[^"]*"|[^<]*<\/title>\n?/g; /** * @param {string} text A page file. * @returns {string} The file without what names it, for telling the * same page in two files. */ function pageContent(text) { const paper = text.indexOf('<rect '); return paper < 0 ? text.replace(VOLATILE, '') : text.slice(0, paper).replace(VOLATILE, '') + text.slice(paper); } /** * @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<IDBDatabase> | null} */ let opening = null; /** @returns {Promise<IDBDatabase>} */ 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<T>} request * @returns {Promise<T>} */ 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<T>} work * @returns {Promise<T>} */ 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<StoredPage[]>} 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, trash: false }; }, 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 same page, whatever names the // file); 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=', '<image'].some((mark) => 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 content = pageContent(current.text); const same = kept.find((page) => pageContent(page.text) === content); 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 }; }); }, // The browser has no trash: a deleted page is gone. async discardNotebook(notebook) { await transaction('readwrite', async (pages) => { for (const page of await pagesOf(pages, notebook)) await result(pages.delete([notebook, page.name])); }); }, async discardPage(notebook, pageName) { await transaction('readwrite', async (pages) => { await result(pages.delete([notebook, pageName])); }); }, };