Files
kokkoja-rel/client/js/browser_notebook.js
T
ackandClaude Fable 5.1 95c80d04c9 Kokkoja: first public assembly
Assembled from the development repository by tools/make_release.sh.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 15:58:02 +02:00

155 lines
5.5 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$/;
/**
* @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 };
},
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])));
if (!current || !(current.text.includes('data-stroke=') || current.text.includes('<image'))) {
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 };
});
},
};