Files
kokkoja-rel/client/js/browser_notebook.js
T
ackandClaude Fable 5.1 87ba7fc776 Tooltips in the Notebooks chooser
From the development repository at e819bc4.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 18:05:49 +02:00

188 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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 <rect>, 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>[^<]*<\/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) => ({ name: page.name, changed: page.changed })));
},
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])); });
},
};