335 lines
11 KiB
JavaScript
335 lines
11 KiB
JavaScript
// @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 {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.
|
|
*/
|
|
constructor(notebook, content, events) {
|
|
this.notebook = notebook;
|
|
this.content = content;
|
|
this.events = events;
|
|
/** @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));
|
|
// 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());
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
keepPage() {
|
|
this.writeNow();
|
|
let result = { kept: /** @type {string | null} */ (null), new: false };
|
|
return this.enqueue(async () => {
|
|
result = await keepCurrentPage(this.notebook);
|
|
this.events.onKept(
|
|
result.kept === null ? 'The page is blank: nothing to keep'
|
|
: result.new ? `Kept as ${result.kept}` : `Already kept as ${result.kept}`);
|
|
}).then(() => result);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
}
|