Kokkoja: first public assembly
Assembled from the development repository by tools/make_release.sh. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
95c80d04c9
39 files changed
+9905
No files matched your search
@@ -0,0 +1,333 @@
|
||||
// @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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user