// @ts-check /** * Notebooks: reading and writing page files. * * A notebook is a directory on the user's computer. The page being written * is the file `Current.svg` in it, rewritten as the page changes. A page is * *kept* by copying `Current.svg` to a dated file such as * `2026_10_02_01.svg`. That happens when the user asks, and by itself * whenever `Current.svg` is about to be given over to a different page, so * that no page is lost by forgetting to keep it. * * The browser cannot reach those directories itself, so it asks the local * program (kokkoja.py), proving with a token that it is the user's own * browser. When Kokkoja is opened from a web site instead, with no local * program, the notebooks are kept in the browser's own storage * (`browser_notebook.js`) under the same names and rules; the user gets * pages out of them as downloads. */ import * as inBrowser from './browser_notebook.js'; const TOKEN_KEY = 'kokkoja_token'; export const CURRENT_PAGE = 'Current.svg'; /** Write this long after the latest change... */ const QUIET_MS = 400; /** ...but never leave a change unwritten for longer than this. */ const LONGEST_WAIT_MS = 2000; /** After a write fails, try again this much later. */ const RETRY_MS = 3000; /** * What keeps the notebooks: the local program's files, or the browser. * @typedef {object} Backend * @property {() => Promise} readIdentity * @property {(text: string) => Promise} keepIdentity * @property {() => Promise<{directory: string, notebooks: string[]}>} listNotebooks * The directory is '' when the notebooks are in the browser. * @property {(notebook: string) => Promise} listPages Every page file, Current.svg too. * @property {(notebook: string, pageName: string) => Promise} readPage * @property {(notebook: string, pageName: string, text: string) => Promise} writePage * @property {(notebook: string) => Promise<{kept: string | null, new: boolean}>} keepPage */ /** * Keep the token for later visits. The local program gives it in the * address it opens. * @param {string} token */ export function rememberToken(token) { localStorage.setItem(TOKEN_KEY, token); } /** * Ask the local program for something, with the token that proves we are * the user's own browser. * @param {string} path * @param {string} [method] * @param {BodyInit} [body] * @returns {Promise} */ export async function askLocalProgram(path, method = 'GET', body = undefined) { const response = await fetch(path, { method, body, headers: { Authorization: `Bearer ${localStorage.getItem(TOKEN_KEY) ?? ''}` }, }); if (response.status === 401) { throw new Error('Notebooks are locked: open Kokkoja from the address that ' + 'the Kokkoja program shows when it starts.'); } return response; } /** * @param {string} method * @param {string[]} names Notebook name, then page name; either may be absent. * @param {string} [body] * @returns {Promise} */ function request(method, names, body) { return askLocalProgram(['api', 'notebooks', ...names.map(encodeURIComponent)].join('/'), method, body); } /** The notebooks as the local program keeps them: directories of files. */ const inFiles = /** @type {Backend} */ ({ async readIdentity() { const response = await askLocalProgram('api/identity'); return response.ok ? response.text() : null; }, async keepIdentity(text) { return (await askLocalProgram('api/identity', 'PUT', text)).ok; }, async listNotebooks() { const response = await request('GET', []); if (!response.ok) throw new Error('Notebooks are not available.'); return response.json(); }, async listPages(notebook) { const response = await request('GET', [notebook]); if (!response.ok) throw new Error(`The notebook “${notebook}” cannot be read.`); return (await response.json()).pages; }, async readPage(notebook, pageName) { const response = await request('GET', [notebook, pageName]); return response.ok ? response.text() : null; }, async writePage(notebook, pageName, text) { const response = await request('PUT', [notebook, pageName], text); if (!response.ok) throw new Error(`The page could not be saved (${response.status}).`); }, async keepPage(notebook) { const response = await request('POST', [notebook, 'keep']); if (!response.ok) throw new Error(`The page could not be kept (${response.status}).`); return response.json(); }, }); let backend = inFiles; /** Keep the notebooks in this browser: there is no local program. */ export function useBrowserStorage() { backend = inBrowser.backend; } /** @returns {boolean} Whether the notebooks are in this browser. */ export function notebooksInBrowser() { return backend === inBrowser.backend; } /** * The user's identity as it is kept, or null if none is kept yet. * @returns {Promise} Text that loadIdentity() accepts. */ export function readIdentity() { return backend.readIdentity(); } /** * Give the user's identity to be kept. Only the first identity is kept; * the result says whether this one was. * @param {string} text From saveIdentity(). * @returns {Promise} */ export function keepIdentity(text) { return backend.keepIdentity(text); } /** * @returns {Promise<{directory: string, notebooks: string[]}>} */ export function listNotebooks() { return backend.listNotebooks(); } /** * @param {string} notebook * @returns {Promise} The names of the pages kept in the * notebook, in order; not the page being written. */ export async function listKeptPages(notebook) { const pages = await backend.listPages(notebook); return pages.filter((name) => name !== CURRENT_PAGE); } /** * @param {string} notebook * @param {string} pageName * @returns {Promise} The page file, or null if there is none. */ export function readPage(notebook, pageName) { return backend.readPage(notebook, pageName); } /** * @param {string} notebook * @returns {Promise} The notebook's current page file, or * null if it has none. */ export function readCurrentPage(notebook) { return backend.readPage(notebook, CURRENT_PAGE); } /** * Keep a copy of the notebook's current page, unless it is blank or an * identical page is already kept. * @param {string} notebook * @returns {Promise<{kept: string | null, new: boolean}>} The name of the * kept page, or null if there was nothing to keep, and whether the copy * was made just now. */ export function keepCurrentPage(notebook) { return backend.keepPage(notebook); } /** * Writes the session's page to the current notebook. There is no save * command: the page is written whenever it changes. * * Everything that touches the notebook's files goes through one queue, so * that writing and keeping happen in the sequence in which they were asked * for. */ export class NotebookWriter { /** * @param {string} notebook * @param {() => string | null} content Produces the file of the present * page, or null if there is no page to save yet. * @param {(message: string, problem: boolean) => void} onStatus */ constructor(notebook, content, onStatus) { this.notebook = notebook; this.content = content; this.onStatus = onStatus; /** @type {number | null} */ this.timer = null; /** Time of the oldest change not yet written, or 0. */ this.waitingSince = 0; /** Time of the latest write. */ this.lastWrite = 0; this.queue = Promise.resolve(); // A change that was waiting is written when the tab is hidden or shown. document.addEventListener('visibilitychange', () => { if (this.waitingSince) this.writeNow(); }); } /** * Add work to the queue. A failure is reported and does not stop later * work. * @param {() => Promise} work * @returns {Promise} */ enqueue(work) { this.queue = this.queue.then(work).catch((error) => { this.onStatus(error instanceof Error ? error.message : String(error), true); // The page is still in memory: try to write it again shortly. if (this.timer === null) this.timer = setTimeout(() => this.writeNow(), RETRY_MS); }); return this.queue; } /** @param {string} text */ put(text) { return backend.writePage(this.notebook, CURRENT_PAGE, text); } /** The page changed: write it soon. */ changed() { const now = performance.now(); if (!this.waitingSince) this.waitingSince = now; if (this.timer !== null) clearTimeout(this.timer); // Browsers delay the timers of a tab that is not in view, perhaps for a // long time. There, write at once instead of waiting for a pause. if (document.visibilityState === 'hidden' && now - this.lastWrite >= QUIET_MS) { this.writeNow(); return; } const delay = Math.max(0, Math.min(QUIET_MS, this.waitingSince + LONGEST_WAIT_MS - now)); this.timer = setTimeout(() => this.writeNow(), delay); } /** * Write the present page without waiting. * @returns {Promise} */ writeNow() { if (this.timer !== null) clearTimeout(this.timer); this.timer = null; this.waitingSince = 0; this.lastWrite = performance.now(); return this.enqueue(async () => { const text = this.content(); if (text === null) return; await this.put(text); this.onStatus(`Saved at ${new Date().toLocaleTimeString()}`, false); }); } /** * The user asked to keep the page as it is now. * @returns {Promise} The name of the kept page, or null * if there was nothing to keep. */ keepPage() { this.writeNow(); /** @type {string | null} */ let kept = null; return this.enqueue(async () => { const result = await keepCurrentPage(this.notebook); kept = result.kept; this.onStatus( result.kept === null ? 'The page is blank: nothing to keep' : result.new ? `Kept as ${result.kept}` : `Already kept as ${result.kept}`, false); }).then(() => kept); } /** * The session moved to another page. Keep the old one, then begin * writing the new one. * @param {string | null} oldText The file of the old page, or null if it * was never a page of ours to save. * @returns {Promise} */ pageReplaced(oldText) { if (this.timer !== null) clearTimeout(this.timer); this.timer = null; this.waitingSince = 0; this.enqueue(async () => { if (oldText !== null) await this.put(oldText); await keepCurrentPage(this.notebook); }); return this.writeNow(); } /** * Change to another notebook. Whatever page that notebook was left with * is kept, unless it is this very page. * @param {string} notebook * @param {(text: string) => boolean} isPresentPage * Whether a page file holds the page we are on. * @returns {Promise} */ changeNotebook(notebook, isPresentPage) { this.writeNow(); this.enqueue(async () => { this.notebook = notebook; const text = await readCurrentPage(notebook); if (text !== null && !isPresentPage(text)) await keepCurrentPage(notebook); }); return this.writeNow(); } }