Files

392 lines
14 KiB
JavaScript
Raw Permalink 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;
2026-10-11 18:05:49 +02:00
/**
* A page file in a notebook.
* @typedef {object} KeptPage
* @property {string} name
* @property {number} changed When it was last written, in milliseconds since the epoch.
*/
2026-10-06 15:58:02 +02:00
/**
* 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[], trash: boolean}>} listNotebooks
* The directory is '' when the notebooks are in the browser; trash
* says whether a deleted notebook or page goes to one, or is gone for good.
2026-10-11 18:05:49 +02:00
* @property {(notebook: string) => Promise<KeptPage[]>} listPages Every page file, Current.svg too.
2026-10-06 15:58:02 +02:00
* @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
* @property {(notebook: string) => Promise<void>} discardNotebook Delete a notebook, pages and all.
* @property {(notebook: string, pageName: string) => Promise<void>} discardPage Delete a kept page.
2026-10-06 15:58:02 +02:00
*/
/**
* 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.`);
2026-10-11 18:05:49 +02:00
const { pages, changed } = await response.json();
return pages.map((/** @type {string} */ name) => ({ name, changed: changed?.[name] ?? 0 }));
2026-10-06 15:58:02 +02:00
},
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();
},
async discardNotebook(notebook) {
const response = await request('DELETE', [notebook]);
if (!response.ok) throw new Error(`The notebook “${notebook}” could not be deleted (${response.status}).`);
},
async discardPage(notebook, pageName) {
const response = await request('DELETE', [notebook, pageName]);
if (!response.ok) throw new Error(`The page ${pageName} could not be deleted (${response.status}).`);
},
2026-10-06 15:58:02 +02:00
});
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[], trash: boolean}>}
2026-10-06 15:58:02 +02:00
*/
export function listNotebooks() {
return backend.listNotebooks();
}
/**
* Delete a notebook with every page in it: to the Trash where the
* local program has one, for good otherwise (listNotebooks says which).
* @param {string} notebook
* @returns {Promise<void>}
*/
export function discardNotebook(notebook) {
return backend.discardNotebook(notebook);
}
/**
* Delete a kept page, likewise.
* @param {string} notebook
* @param {string} pageName
* @returns {Promise<void>}
*/
export function discardPage(notebook, pageName) {
return backend.discardPage(notebook, pageName);
}
2026-10-06 15:58:02 +02:00
/**
* @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) {
2026-10-11 18:05:49 +02:00
return (await listKeptPageDetails(notebook)).map((page) => page.name);
}
/**
* @param {string} notebook
* @returns {Promise<KeptPage[]>} The pages kept in the notebook, in
* order, with when each was last changed; not the page being written.
*/
export async function listKeptPageDetails(notebook) {
const pages = await backend.listPages(notebook);
return pages.filter((page) => page.name !== CURRENT_PAGE);
}
/**
* @param {string} notebook
* @returns {Promise<number>} When the page being written in the notebook
* was last written, in milliseconds since the epoch, or 0 if there is none.
*/
export async function currentPageChanged(notebook) {
2026-10-06 15:58:02 +02:00
const pages = await backend.listPages(notebook);
2026-10-11 18:05:49 +02:00
return pages.find((page) => page.name === CURRENT_PAGE)?.changed ?? 0;
2026-10-06 15:58:02 +02:00
}
/**
* @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 {object} events
* @param {(time: string) => void} events.onSaved The page was written, at that time of day.
* @param {(message: string) => void} events.onKept What keeping the page did.
* @param {(message: string) => void} events.onProblem A write or keep failed.
2026-10-06 15:58:02 +02:00
*/
constructor(notebook, content, events) {
2026-10-06 15:58:02 +02:00
this.notebook = notebook;
this.content = content;
this.events = events;
2026-10-06 15:58:02 +02:00
/** @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.events.onProblem(error instanceof Error ? error.message : String(error));
2026-10-06 15:58:02 +02:00
// 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.events.onSaved(new Date().toLocaleTimeString());
2026-10-06 15:58:02 +02:00
});
}
/**
* The user asked to keep the page as it is now.
* @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.
2026-10-06 15:58:02 +02:00
*/
keepPage() {
this.writeNow();
let result = { kept: /** @type {string | null} */ (null), new: false };
2026-10-06 15:58:02 +02:00
return this.enqueue(async () => {
result = await keepCurrentPage(this.notebook);
this.events.onKept(
2026-10-06 15:58:02 +02:00
result.kept === null ? 'The page is blank: nothing to keep'
: result.new ? `Kept as ${result.kept}` : `Already kept as ${result.kept}`);
}).then(() => result);
2026-10-06 15:58:02 +02:00
}
/**
* 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();
}
}