Files
kokkoja-rel/client/js/notebook.js
T

333 lines
11 KiB
JavaScript
Raw Normal View History

2026-10-06 15:58:02 +02:00
// @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<string | null>} readIdentity
* @property {(text: string) => Promise<boolean>} keepIdentity
* @property {() => Promise<{directory: string, notebooks: string[]}>} listNotebooks
* The directory is '' when the notebooks are in the browser.
* @property {(notebook: string) => Promise<string[]>} listPages Every page file, Current.svg too.
* @property {(notebook: string, pageName: string) => Promise<string | null>} readPage
* @property {(notebook: string, pageName: string, text: string) => Promise<void>} 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<Response>}
*/
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<Response>}
*/
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<string | null>} 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<boolean>}
*/
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<string[]>} 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<string | null>} The page file, or null if there is none.
*/
export function readPage(notebook, pageName) {
return backend.readPage(notebook, pageName);
}
/**
* @param {string} notebook
* @returns {Promise<string | null>} 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<void>} work
* @returns {Promise<void>}
*/
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<void>}
*/
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<string | null>} 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<void>}
*/
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<void>}
*/
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();
}
}