A host from the web site; the same page kept once; deleting from the Notebooks chooser

From the development repository at 299f73d, 2026-10-11:
hosting from the site documented; a page loaded from a notebook and left
unchanged is not kept again; notebooks and pages are deleted from the
chooser, to the system's Trash (--trash); the Notebooks button.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ackandClaude Fable 5.1 committed 2026-10-11 16:59:03 +02:00
1 parent 4e3a281d65
commit 0d01e23d7c
15 files changed
+711 -63

No files matched your search

+4 -2
View File
@@ -27,9 +27,11 @@ may be used.
## In short ## In short
A guest needs nothing but a web browser: open the invitation link, enter A guest needs nothing but a web browser: open the invitation link, enter
a name, join. a name, join. A host can start a session from the same web site,
`https://kokkoja.andykopra.com/`, with the notebooks kept in the
browser.
A host needs Python 3 and this directory: A host who wants notebooks as files needs Python 3 and this directory:
python3 kokkoja.py --relay wss://kokkoja.andykopra.com/relay python3 kokkoja.py --relay wss://kokkoja.andykopra.com/relay
+3 -1
View File
@@ -93,7 +93,7 @@
<button id="load_image" type="button" <button id="load_image" type="button"
data-help="Put an image file, such as a scanned exercise, on the page for everyone: below the images already there, centred, at its own size. Rest the pen on an image to move it, or near a corner to scale it; it comes to the front. A tap of the eraser on an image removes it; writing on it stays.">Image</button> data-help="Put an image file, such as a scanned exercise, on the page for everyone: below the images already there, centred, at its own size. Rest the pen on an image to move it, or near a corner to scale it; it comes to the front. A tap of the eraser on an image removes it; writing on it stays.">Image</button>
<button id="load_notebook_page" type="button" <button id="load_notebook_page" type="button"
data-help="Start a new page for everyone from a page kept in a notebook, with everything on it. The current page is kept in each notebook.">Notebook</button> data-help="Start a new page for everyone from a page kept in a notebook, with everything on it. The current page is kept in each notebook.">Notebooks</button>
</div> </div>
<div class="group" aria-label="Notebook"> <div class="group" aria-label="Notebook">
<select id="session_notebook" class="notebook_pick" aria-label="Current notebook" <select id="session_notebook" class="notebook_pick" aria-label="Current notebook"
@@ -152,6 +152,8 @@
<dialog id="notebook_chooser" class="chooser"> <dialog id="notebook_chooser" class="chooser">
<header> <header>
<h2>Notebooks</h2> <h2>Notebooks</h2>
<button id="chooser_delete" type="button" class="delete" hidden
data-help="Delete the marked notebooks and pages. The first press says what will happen; the second does it.">Delete</button>
<button id="chooser_close" type="button" aria-label="Close">×</button> <button id="chooser_close" type="button" aria-label="Close">×</button>
</header> </header>
<div class="chooser_body"> <div class="chooser_body">
+37 -4
View File
@@ -17,6 +17,26 @@ const PAGES = 'pages';
const IDENTITY_KEY = 'kokkoja_identity'; const IDENTITY_KEY = 'kokkoja_identity';
const CURRENT_PAGE = 'Current.svg'; const CURRENT_PAGE = 'Current.svg';
const KEPT_PAGE = /^(\d{4}_\d{2}_\d{2})_(\d{2,})\.svg$/; const KEPT_PAGE = /^(\d{4}_\d{2}_\d{2})_(\d{2,})\.svg$/;
/**
* What names a page file rather than describes the page: the session,
* the host, the page's id and number on the root element, and the title.
* A page loaded from a notebook is a new page with new ones of these, so
* two files of the same page differ here and nowhere else. They stand
* before the paper's <rect>, and only there; typed text may contain
* anything. The same rule as page_content() in kokkoja.py.
*/
const VOLATILE = / data-(?:session|host|page|number)="[^"]*"|<title>[^<]*<\/title>\n?/g;
/**
* @param {string} text A page file.
* @returns {string} The file without what names it, for telling the
* same page in two files.
*/
function pageContent(text) {
const paper = text.indexOf('<rect ');
return paper < 0 ? text.replace(VOLATILE, '')
: text.slice(0, paper).replace(VOLATILE, '') + text.slice(paper);
}
/** /**
* @typedef {object} StoredPage * @typedef {object} StoredPage
@@ -112,7 +132,7 @@ export const backend = {
const keys = /** @type {[string, string][]} */ (await result(pages.getAllKeys())); const keys = /** @type {[string, string][]} */ (await result(pages.getAllKeys()));
return [...new Set(keys.map(([notebook]) => notebook))].sort(); return [...new Set(keys.map(([notebook]) => notebook))].sort();
}); });
return { directory: '', notebooks: names }; return { directory: '', notebooks: names, trash: false };
}, },
async listPages(notebook) { async listPages(notebook) {
@@ -132,15 +152,17 @@ export const backend = {
}, },
// The same rules as keep_page() in kokkoja.py: nothing is kept of a // The same rules as keep_page() in kokkoja.py: nothing is kept of a
// blank page or of one already kept; the name is the day the page was // blank page or of one already kept (the same page, whatever names the
// last changed and a serial number. // file); the name is the day the page was last changed and a serial
// number.
async keepPage(notebook) { async keepPage(notebook) {
return transaction('readwrite', async (pages) => { return transaction('readwrite', async (pages) => {
const current = /** @type {StoredPage | undefined} */ (await result(pages.get([notebook, CURRENT_PAGE]))); const current = /** @type {StoredPage | undefined} */ (await result(pages.get([notebook, CURRENT_PAGE])));
const blank = !['data-stroke=', 'data-text=', '<image'].some((mark) => current?.text.includes(mark)); const blank = !['data-stroke=', 'data-text=', '<image'].some((mark) => current?.text.includes(mark));
if (!current || blank) return { kept: null, new: false }; if (!current || blank) return { kept: null, new: false };
const kept = (await pagesOf(pages, notebook)).filter((page) => KEPT_PAGE.test(page.name)); const kept = (await pagesOf(pages, notebook)).filter((page) => KEPT_PAGE.test(page.name));
const same = kept.find((page) => page.text === current.text); const content = pageContent(current.text);
const same = kept.find((page) => pageContent(page.text) === content);
if (same) return { kept: same.name, new: false }; if (same) return { kept: same.name, new: false };
const day = dayOf(current.changed); const day = dayOf(current.changed);
const serials = kept.map((page) => /** @type {RegExpExecArray} */ (KEPT_PAGE.exec(page.name))) const serials = kept.map((page) => /** @type {RegExpExecArray} */ (KEPT_PAGE.exec(page.name)))
@@ -150,4 +172,15 @@ export const backend = {
return { kept: name, new: true }; return { kept: name, new: true };
}); });
}, },
// The browser has no trash: a deleted page is gone.
async discardNotebook(notebook) {
await transaction('readwrite', async (pages) => {
for (const page of await pagesOf(pages, notebook)) await result(pages.delete([notebook, page.name]));
});
},
async discardPage(notebook, pageName) {
await transaction('readwrite', async (pages) => { await result(pages.delete([notebook, pageName])); });
},
}; };
+194 -11
View File
@@ -2,9 +2,19 @@
/** /**
* The notebook chooser: the "Notebook" button's dialog, which shows the * The notebook chooser: the "Notebook" button's dialog, which shows the
* notebooks and the pages kept in them, and loads the chosen page. * notebooks and the pages kept in them, and loads the chosen page.
*
* It also deletes (Andy, 2026-10-11): every notebook and every page card
* carries a mark, ×, that a tap turns into a tick, and one "Delete"
* button in the header, which appears as soon as something is marked,
* says how much is marked and asks for a second press before anything
* goes, as the file chooser does for replacing a file; no system dialog.
* One gesture serves one page and twenty. Where the local program has
* a Trash the entries are moved there, and the button says so;
* in the browser they are gone for good, and the button says that. The
* notebook being written in cannot be marked: its mark explains.
*/ */
import { listKeptPages, listNotebooks, readPage } from './notebook.js'; import { discardNotebook, discardPage, listKeptPages, listNotebooks, readPage } from './notebook.js';
/** /**
* @param {string} id * @param {string} id
@@ -23,35 +33,80 @@ function pageTitle(pageName) {
return match ? `${match[1]}-${match[2]}-${match[3]} (${Number(match[4])})` : pageName; return match ? `${match[1]}-${match[2]}-${match[3]} (${Number(match[4])})` : pageName;
} }
/**
* @param {number} count
* @param {string} noun
* @returns {string} "1 page", "3 pages".
*/
function counted(count, noun) {
return `${count} ${noun}${count === 1 ? '' : 's'}`;
}
/**
* A mark button: × until marked, then ✓.
* @param {string} help The tooltip.
* @returns {HTMLButtonElement}
*/
function markButton(help) {
const mark = document.createElement('button');
mark.type = 'button';
mark.className = 'mark';
mark.textContent = '×';
mark.setAttribute('aria-label', 'Mark for deletion');
mark.setAttribute('aria-pressed', 'false');
mark.dataset.help = help;
return mark;
}
export class NotebookChooser { export class NotebookChooser {
/** /**
* @param {(notebook: string, pageName: string, text: string) => void} onChosen * @param {(notebook: string, pageName: string, text: string) => void} onChosen
* Called with the chosen page file. * Called with the chosen page file.
* @param {() => void} [onDeleted] Called after notebooks or pages were
* deleted, for lists elsewhere that name them.
*/ */
constructor(onChosen) { constructor(onChosen, onDeleted = () => {}) {
this.onChosen = onChosen; this.onChosen = onChosen;
this.onDeleted = onDeleted;
this.dialog = /** @type {HTMLDialogElement} */ (element('notebook_chooser')); this.dialog = /** @type {HTMLDialogElement} */ (element('notebook_chooser'));
this.notebook = ''; this.notebook = '';
/** The notebook being written in, which cannot be deleted while it is. */
this.writing = '';
/** Whether deletions go to a Trash (the local program's) or are for good. */
this.trash = false;
/** Counts openings, so that pictures fetched for an earlier one are dropped. */ /** Counts openings, so that pictures fetched for an earlier one are dropped. */
this.opening = 0; this.opening = 0;
/** @type {string[]} */ /** @type {string[]} */
this.pictureAddresses = []; this.pictureAddresses = [];
/** @type {Set<string>} Notebooks marked for deletion. */
this.markedNotebooks = new Set();
/** @type {Map<string, Set<string>>} Pages marked for deletion, by notebook. */
this.markedPages = new Map();
/** Whether the Delete button has been pressed once and awaits its second press. */
this.armed = false;
element('chooser_close').addEventListener('click', () => this.dialog.close()); element('chooser_close').addEventListener('click', () => this.dialog.close());
this.dialog.addEventListener('close', () => this.forgetPictures()); element('chooser_delete').addEventListener('click', () => { this.deleteMarked().catch(() => {}); });
this.dialog.addEventListener('close', () => {
this.forgetPictures();
this.clearMarks();
});
} }
/** /**
* Show the notebooks, or the pages of the only notebook. * Show the notebooks, or the pages of the only notebook.
* @param {string} [notebook] Open on this notebook. * @param {string} [notebook] Open on this notebook: the one being written in.
*/ */
async open(notebook) { async open(notebook) {
this.opening += 1; this.opening += 1;
this.writing = notebook ?? '';
this.clearMarks();
this.dialog.showModal(); this.dialog.showModal();
element('chooser_message').textContent = ''; element('chooser_message').textContent = '';
element('chooser_pages').replaceChildren(); // Nothing stale while the lists load. element('chooser_pages').replaceChildren(); // Nothing stale while the lists load.
try { try {
const { notebooks } = await listNotebooks(); const { notebooks, trash } = await listNotebooks();
this.trash = trash;
this.showNotebooks(notebooks); this.showNotebooks(notebooks);
// The notebook being written in is chosen already, its pages shown; // The notebook being written in is chosen already, its pages shown;
// the list is always there to see (Andy, 2026-10-06). // the list is always there to see (Andy, 2026-10-06).
@@ -59,44 +114,69 @@ export class NotebookChooser {
: notebooks.length === 1 ? notebooks[0] : ''; : notebooks.length === 1 ? notebooks[0] : '';
await this.showPages(chosen); await this.showPages(chosen);
} catch (error) { } catch (error) {
element('chooser_message').textContent = error instanceof Error ? error.message : String(error); this.say(error instanceof Error ? error.message : String(error));
} }
} }
/** @param {string} text */
say(text) {
element('chooser_message').textContent = text;
}
/** @param {string[]} notebooks */ /** @param {string[]} notebooks */
showNotebooks(notebooks) { showNotebooks(notebooks) {
const list = element('chooser_notebooks'); const list = element('chooser_notebooks');
list.replaceChildren(); list.replaceChildren();
for (const notebook of notebooks) { for (const notebook of notebooks) {
const row = document.createElement('div');
row.className = 'notebook_row';
const button = document.createElement('button'); const button = document.createElement('button');
button.type = 'button'; button.type = 'button';
button.className = 'notebook_pick';
button.textContent = notebook; button.textContent = notebook;
button.addEventListener('click', () => { this.showPages(notebook); }); button.addEventListener('click', () => { this.showPages(notebook); });
list.append(button); const mark = markButton(notebook === this.writing
? 'This is the notebook you are writing in. To delete it, choose another notebook in the toolbar first.'
: 'Mark this notebook, with every page in it, for deletion. The Delete button above then deletes what is marked.');
if (notebook === this.writing) mark.classList.add('unavailable');
mark.addEventListener('click', () => {
if (notebook === this.writing) {
this.say('You are writing in this notebook. To delete it, choose another notebook in the toolbar first.');
return;
} }
this.toggleMark(mark, this.markedNotebooks, notebook);
});
row.append(button, mark);
list.append(row);
}
this.showMarks();
} }
/** @param {string} notebook */ /** @param {string} notebook */
async showPages(notebook) { async showPages(notebook) {
this.notebook = notebook; this.notebook = notebook;
for (const button of element('chooser_notebooks').querySelectorAll('button')) { this.disarm();
for (const button of element('chooser_notebooks').querySelectorAll('button.notebook_pick')) {
button.setAttribute('aria-pressed', String(button.textContent === notebook)); button.setAttribute('aria-pressed', String(button.textContent === notebook));
} }
const grid = element('chooser_pages'); const grid = element('chooser_pages');
grid.replaceChildren(); grid.replaceChildren();
this.forgetPictures(); this.forgetPictures();
if (!notebook) { if (!notebook) {
element('chooser_message').textContent = 'Choose a notebook.'; this.say('Choose a notebook.');
return; return;
} }
const opening = this.opening += 1; const opening = this.opening += 1;
const pages = await listKeptPages(notebook); const pages = await listKeptPages(notebook);
if (opening !== this.opening) return; if (opening !== this.opening) return;
element('chooser_message').textContent = pages.length ? '' : 'This notebook has no kept pages.'; this.say(pages.length ? '' : 'This notebook has no kept pages.');
const marked = this.markedPages.get(notebook) ?? new Set();
/** @type {Map<string, HTMLImageElement>} */ /** @type {Map<string, HTMLImageElement>} */
const pictures = new Map(); const pictures = new Map();
for (const pageName of pages) { for (const pageName of pages) {
const slot = document.createElement('div');
slot.className = 'page_slot';
const card = document.createElement('button'); const card = document.createElement('button');
card.type = 'button'; card.type = 'button';
card.className = 'page_card'; card.className = 'page_card';
@@ -112,8 +192,17 @@ export class NotebookChooser {
this.dialog.close(); this.dialog.close();
this.onChosen(notebook, pageName, text); this.onChosen(notebook, pageName, text);
}); });
grid.append(card); const mark = markButton('Mark this page for deletion. The Delete button above then deletes what is marked.');
mark.addEventListener('click', () => { this.toggleMark(mark, marked, pageName); });
if (marked.has(pageName)) {
mark.textContent = '✓';
mark.setAttribute('aria-pressed', 'true');
slot.classList.add('marked');
} }
slot.append(card, mark);
grid.append(slot);
}
this.markedPages.set(notebook, marked);
// The pictures are the page files themselves, fetched one by one. // The pictures are the page files themselves, fetched one by one.
for (const [pageName, picture] of pictures) { for (const [pageName, picture] of pictures) {
@@ -126,6 +215,100 @@ export class NotebookChooser {
} }
} }
/**
* Mark or unmark one entry.
* @param {HTMLButtonElement} mark
* @param {Set<string>} marked The set the entry belongs in.
* @param {string} name
*/
toggleMark(mark, marked, name) {
const now = !marked.has(name);
if (now) marked.add(name); else marked.delete(name);
mark.textContent = now ? '✓' : '×';
mark.setAttribute('aria-pressed', String(now));
/** @type {HTMLElement} */ (mark.parentElement).classList.toggle('marked', now);
this.disarm();
this.showMarks();
}
/** @returns {{notebooks: number, pages: number}} What is marked, a marked notebook's pages not counted. */
countMarks() {
let pages = 0;
for (const [notebook, names] of this.markedPages) {
if (!this.markedNotebooks.has(notebook)) pages += names.size;
}
return { notebooks: this.markedNotebooks.size, pages };
}
/** @returns {string} "1 notebook and 3 pages", or '' when nothing is marked. */
describeMarks() {
const { notebooks, pages } = this.countMarks();
const parts = [];
if (notebooks) parts.push(counted(notebooks, 'notebook'));
if (pages) parts.push(counted(pages, 'page'));
return parts.join(' and ');
}
/** The Delete button says what is marked, or is hidden when nothing is. */
showMarks() {
const button = element('chooser_delete');
const what = this.describeMarks();
button.hidden = !what;
if (!what) return;
button.textContent = this.armed
? (this.trash ? `Press again to move ${what} to the Trash`
: `Press again to delete ${what} for good`)
: `Delete ${what}?`;
}
/** The second press is no longer awaited: something else happened. */
disarm() {
if (!this.armed) return;
this.armed = false;
this.showMarks();
}
clearMarks() {
this.markedNotebooks.clear();
this.markedPages.clear();
this.armed = false;
element('chooser_delete').hidden = true;
}
/** The Delete button: arm on the first press, delete on the second. */
async deleteMarked() {
if (!this.armed) {
this.armed = true;
this.showMarks();
return;
}
const what = this.describeMarks();
const notebooks = [...this.markedNotebooks];
const pages = [...this.markedPages].filter(([notebook]) => !this.markedNotebooks.has(notebook));
this.clearMarks();
let failed = '';
try {
for (const notebook of notebooks) await discardNotebook(notebook);
for (const [notebook, names] of pages) {
for (const name of names) await discardPage(notebook, name);
}
} catch (error) {
failed = error instanceof Error ? error.message : String(error);
}
this.onDeleted();
try {
const listed = await listNotebooks();
this.showNotebooks(listed.notebooks);
await this.showPages(listed.notebooks.includes(this.notebook) ? this.notebook : '');
} catch (error) {
this.say(error instanceof Error ? error.message : String(error));
return;
}
const sentence = what.charAt(0).toUpperCase() + what.slice(1);
this.say(failed ? `Deleting stopped: ${failed}`
: this.trash ? `${sentence} moved to the Trash.` : `${sentence} deleted.`);
}
forgetPictures() { forgetPictures() {
for (const address of this.pictureAddresses) URL.revokeObjectURL(address); for (const address of this.pictureAddresses) URL.revokeObjectURL(address);
this.pictureAddresses = []; this.pictureAddresses = [];
+5 -2
View File
@@ -9,7 +9,7 @@ import {
parseInvitation, saveIdentity, parseInvitation, saveIdentity,
} from './crypto.js'; } from './crypto.js';
import { import {
NotebookWriter, keepCurrentPage, keepIdentity, listKeptPages, listNotebooks, notebooksInBrowser, NotebookWriter, discardNotebook, discardPage, keepCurrentPage, keepIdentity, listKeptPages, listNotebooks, notebooksInBrowser,
readCurrentPage, readIdentity, readPage, rememberToken, useBrowserStorage, readCurrentPage, readIdentity, readPage, rememberToken, useBrowserStorage,
} from './notebook.js'; } from './notebook.js';
import { download, pickImageFile } from './browser_files.js'; import { download, pickImageFile } from './browser_files.js';
@@ -1091,6 +1091,9 @@ async function enterSession(invitationText, name, notebook) {
} }
log(`Notebook page loaded: ${chosenNotebook} / ${pageName}`); log(`Notebook page loaded: ${chosenNotebook} / ${pageName}`);
session.loadPage(kept.page); session.loadPage(kept.page);
}, () => {
// A deleted notebook leaves the toolbar's list.
showNotebooks(sessionPicker).catch(() => {});
}); });
element('load_notebook_page').addEventListener('click', () => { chooser.open(writer.notebook); }); element('load_notebook_page').addEventListener('click', () => { chooser.open(writer.notebook); });
@@ -1218,7 +1221,7 @@ async function enterSession(invitationText, name, notebook) {
// For looking at the session from the browser's console during development. // For looking at the session from the browser's console during development.
Object.assign(window, { kokkoja: { Object.assign(window, { kokkoja: {
session, renderer, writer, loadImage, textEditor, session, renderer, writer, loadImage, textEditor,
notebook: { listNotebooks, listKeptPages, readPage, readCurrentPage, notebooksInBrowser }, notebook: { listNotebooks, listKeptPages, readPage, readCurrentPage, notebooksInBrowser, discardNotebook, discardPage },
} }); } });
} }
+34 -3
View File
@@ -34,12 +34,15 @@ const RETRY_MS = 3000;
* @typedef {object} Backend * @typedef {object} Backend
* @property {() => Promise<string | null>} readIdentity * @property {() => Promise<string | null>} readIdentity
* @property {(text: string) => Promise<boolean>} keepIdentity * @property {(text: string) => Promise<boolean>} keepIdentity
* @property {() => Promise<{directory: string, notebooks: string[]}>} listNotebooks * @property {() => Promise<{directory: string, notebooks: string[], trash: boolean}>} listNotebooks
* The directory is '' when the notebooks are in the browser. * 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.
* @property {(notebook: string) => Promise<string[]>} listPages Every page file, Current.svg too. * @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) => Promise<string | null>} readPage
* @property {(notebook: string, pageName: string, text: string) => Promise<void>} writePage * @property {(notebook: string, pageName: string, text: string) => Promise<void>} writePage
* @property {(notebook: string) => Promise<{kept: string | null, new: boolean}>} keepPage * @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.
*/ */
/** /**
@@ -113,6 +116,14 @@ const inFiles = /** @type {Backend} */ ({
if (!response.ok) throw new Error(`The page could not be kept (${response.status}).`); if (!response.ok) throw new Error(`The page could not be kept (${response.status}).`);
return response.json(); 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}).`);
},
}); });
let backend = inFiles; let backend = inFiles;
@@ -146,12 +157,32 @@ export function keepIdentity(text) {
} }
/** /**
* @returns {Promise<{directory: string, notebooks: string[]}>} * @returns {Promise<{directory: string, notebooks: string[], trash: boolean}>}
*/ */
export function listNotebooks() { export function listNotebooks() {
return backend.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);
}
/** /**
* @param {string} notebook * @param {string} notebook
* @returns {Promise<string[]>} The names of the pages kept in the * @returns {Promise<string[]>} The names of the pages kept in the
+40 -1
View File
@@ -404,6 +404,23 @@ h2 {
.chooser header { display: flex; align-items: center; } .chooser header { display: flex; align-items: center; }
.chooser header h2 { flex: 1; margin: 0; } .chooser header h2 { flex: 1; margin: 0; }
.chooser header button { width: 2rem; padding: 0.2rem 0; font-size: 1.1rem; line-height: 1; } .chooser header button { width: 2rem; padding: 0.2rem 0; font-size: 1.1rem; line-height: 1; }
/* The notebook chooser's title takes the width of the notebook column,
so that the Delete button starts where the page cards start, away
from the close mark, which stays at the right (Andy, 2026-10-11).
The button is shaped like the notebook buttons on the left, and the
header is as tall as such a button (its line box, padding and
borders) whether the button is shown or not, so that the body does
not move when it appears. */
#notebook_chooser header { min-height: calc(1lh + 0.7rem + 2px); }
#notebook_chooser header h2 { flex: 0 0 13rem; }
#notebook_chooser header #chooser_close { margin-left: auto; }
.chooser header button.delete {
width: auto;
padding: 0.35rem 0.8rem;
font-size: inherit;
line-height: normal;
color: #b42318;
}
.chooser_body { .chooser_body {
display: flex; display: flex;
@@ -420,7 +437,29 @@ h2 {
overflow: auto; overflow: auto;
} }
.chooser_notebooks button { text-align: left; } .chooser_notebooks button.notebook_pick { flex: 1; min-width: 0; text-align: left; }
/* Rows and cards carry a mark, ×, for deletion; marked, they dim. */
.notebook_row { display: flex; gap: 0.3rem; align-items: stretch; }
.notebook_row.marked button.notebook_pick, .page_slot.marked .page_card { opacity: 0.45; }
.page_slot { position: relative; display: flex; }
.page_slot .page_card { flex: 1; }
button.mark {
width: 1.6rem;
padding: 0;
font-size: 1.1rem;
line-height: 1;
color: var(--muted);
}
.page_slot button.mark {
position: absolute;
top: 0.3rem;
right: 0.3rem;
height: 1.6rem;
background: white;
}
button.mark[aria-pressed="true"] { color: #b42318; }
button.mark.unavailable { opacity: 0.4; }
.chooser_pages { .chooser_pages {
display: grid; display: grid;
+6 -3
View File
@@ -257,7 +257,7 @@ If you would rather have your pages as files in a directory on your own
computer, where you can open, copy and back them up as you do any file, computer, where you can open, copy and back them up as you do any file,
install the Kokkoja program. It is small and needs only Python, which install the Kokkoja program. It is small and needs only Python, which
Linux and macOS have or can quickly get; [A host's Kokkoja](host.md) Linux and macOS have or can quickly get; [A host's Kokkoja](host.md)
tells how, since every host needs it. Then join from the program's own tells how. Then join from the program's own
start screen, pasting the invitation, or follow the start screen's offer start screen, pasting the invitation, or follow the start screen's offer
to open the invitation in the installed program. to open the invitation in the installed program.
@@ -300,10 +300,13 @@ right of your toolbar:
size, centred, 5 mm below the image above or at the top of the page; size, centred, 5 mm below the image above or at the top of the page;
one wider than the page widens it. To put an image on a page of its one wider than the page widens it. To put an image on a page of its
own, press **New** first. own, press **New** first.
* **Notebook** starts a new page for everyone from a page kept in one of * **Notebooks** starts a new page for everyone from a page kept in one of
your notebooks, with everything that was written on it. A chooser your notebooks, with everything that was written on it. A chooser
shows your notebooks and small pictures of their kept pages. The kept shows your notebooks and small pictures of their kept pages. The kept
page itself is not changed: the session works on a copy. page itself is not changed: the session works on a copy. The same
chooser deletes pages and notebooks of yours, marked with their ×
and then the **Delete** button, pressed twice; the host's part says
more.
* **Hide**, among the tools, hides part of the page for everyone: drag * **Hide**, among the tools, hides part of the page for everyone: drag
open a rectangle and whatever lies under it, writing included, is open a rectangle and whatever lies under it, writing included, is
covered in white, marked by blue corners so that nobody writes there covered in white, marked by blue corners so that nobody writes there
+45 -12
View File
@@ -4,17 +4,32 @@ The host starts a session, invites the guests and drives the session:
puts images on the page, starts new pages, loads pages from a notebook, puts images on the page, starts new pages, loads pages from a notebook,
and decides which guests may do the same. In a lesson, the teacher is and decides which guests may do the same. In a lesson, the teacher is
the host. Everything in [A guest's Kokkoja](guest.md) applies to the host the host. Everything in [A guest's Kokkoja](guest.md) applies to the host
too; this part describes what is added, and how to install the Kokkoja too; this part describes what is added.
program, which a host needs.
## Hosting from the web site, or with the Kokkoja program
A host, like a guest, can use Kokkoja with nothing installed: open
`https://kokkoja.andykopra.com/` in a browser, enter a name, press
**Start a session**, and the invitation is ready in the Session window.
Your notebooks are then kept inside that browser, as a guest's are (see
"Your notebook" in the guest's part): they belong to that browser on
that computer, and a page leaves it by **Keep** or **Export**, which
download it. "Image" asks for the file with the browser's own chooser.
If you would rather have your notebooks as ordinary directories of
files, where you can open, copy, back up and send them, run the Kokkoja
program on your own computer. It is a small Python program with two
jobs: it shows Kokkoja in your web browser, and it reads and writes
your notebooks as directories and files. It listens only to your own
computer and talks to nothing else but the relay you name. Your
identity, too, then belongs to your computer rather than to one
browser, so your writing from an earlier lesson is yours from any
browser there. A teacher who keeps notebooks of exercises and a
notebook per student will want the program; for a first session the
web site is enough.
## Installing the Kokkoja program ## Installing the Kokkoja program
A guest can join from a web site with nothing installed. A host runs the
Kokkoja program on their own computer. It is a small Python program with
two jobs: it shows Kokkoja in your web browser, and it reads and writes
your notebooks as ordinary directories and files. It listens only to
your own computer and talks to nothing else but the relay you name.
You need: You need:
* **Linux or macOS.** Windows has not been tried yet. * **Linux or macOS.** Windows has not been tried yet.
@@ -97,6 +112,8 @@ Other options, all optional:
yourself. yourself.
* `--site ADDRESS` names the web site that invitations link to. You * `--site ADDRESS` names the web site that invitations link to. You
need it only if your server's web site is not at the relay's host. need it only if your server's web site is not at the relay's host.
* `--trash DIRECTORY` is where deleted notebooks and pages are moved,
instead of your system's Trash; `--trash none` deletes them outright.
## Starting a session ## Starting a session
@@ -217,7 +234,7 @@ just large enough for everything on it, images and writing, with the
margin of 5 mm around, which trims a page down to its exercise before you keep margin of 5 mm around, which trims a page down to its exercise before you keep
or export it. A blank page cannot shrink. or export it. A blank page cannot shrink.
**Notebook** starts a new page for everyone from a page kept in one of **Notebooks** starts a new page for everyone from a page kept in one of
your notebooks, with everything that was written on it. A chooser shows your notebooks, with everything that was written on it. A chooser shows
your notebooks on the left and small pictures of the chosen notebook's your notebooks on the left and small pictures of the chosen notebook's
kept pages on the right. The loaded page is a copy: the kept page is kept pages on the right. The loaded page is a copy: the kept page is
@@ -226,9 +243,23 @@ belongs to the participant who wrote it, so a student's answers from an
earlier lesson are still theirs, and your corrections still yours. earlier lesson are still theirs, and your corrections still yours.
This is also how you return to an earlier page of the session: the page This is also how you return to an earlier page of the session: the page
was kept when you left it, and **Notebook** brings it back as a new page. was kept when you left it, and **Notebooks** brings it back as a new page.
A lesson might go: **New**, **Image** for the exercise, work, **New** A lesson might go: **New**, **Image** for the exercise, work, **New**
for a point of grammar, work, **Notebook** to return to the exercise. for a point of grammar, work, **Notebooks** to return to the exercise.
The same chooser deletes. Every page card and every notebook in the
list carries a small × mark; a tap marks it, a second tap unmarks it,
and a marked notebook goes with every page in it. As soon as something
is marked, a **Delete** button in the chooser's header says how much:
"Delete 3 pages?", "Delete 1 notebook and 3 pages?". The first press
says what will happen, "Press again to move 3 pages to the Trash";
the second does it. With the Kokkoja program, deleted notebooks and
pages are moved to your system's Trash, where the
file manager can put them back; the program's `--trash` option
names another directory, or `none` to delete outright. In the browser
there is no Trash, and the button says "for good". The notebook
you are writing in cannot be deleted while you are: choose another
notebook in the toolbar first.
## Co-hosts ## Co-hosts
@@ -257,7 +288,9 @@ The page you are on is written continuously to `Current.svg` in the
current notebook, and copied to a dated file whenever the session moves current notebook, and copied to a dated file whenever the session moves
on. **Keep** makes such a copy now, named for the day and a serial on. **Keep** makes such a copy now, named for the day and a serial
number, such as `2026_10_06_02.svg`; pressing it again before anything number, such as `2026_10_06_02.svg`; pressing it again before anything
has changed makes no second copy. Any mark counts as a change, even a has changed makes no second copy, and neither does moving on from a
page that is already kept, such as one loaded from the notebook and left
as it was. Any mark counts as a change, even a
dot from a stray touch of the pen. Use it to mark a state worth keeping dot from a stray touch of the pen. Use it to mark a state worth keeping
on its own, or to add a finished page to a notebook of exercises. on its own, or to add a finished page to a notebook of exercises.
+11 -3
View File
@@ -26,8 +26,9 @@ Kokkoja is three programs and a few static files.
session and nothing more: it cannot read them and it stores nothing. session and nothing more: it cannot read them and it stores nothing.
It is Python with one library, `websockets`. It is Python with one library, `websockets`.
* **The web site** is the `client/` directory served over HTTPS, with a * **The web site** is the `client/` directory served over HTTPS, with a
`config.json` beside it, so that a guest can join without the local `config.json` beside it, so that a guest can join, and a host can
program. `deploy/` has the server setup. start a session, without the local program. `deploy/` has the server
setup.
Every participant's computer holds the whole page. Only changes travel: Every participant's computer holds the whole page. Only changes travel:
a stroke as it is drawn, a text block as it is typed, an erasure, a new a stroke as it is drawn, a text block as it is typed, an erasure, a new
@@ -173,7 +174,14 @@ the user asks (**Keep**), and by the program whenever `Current.svg`
is about to be given to another page: when the session moves to a new is about to be given to another page: when the session moves to a new
page, when the notebook is changed, and when a session begins and finds page, when the notebook is changed, and when a session begins and finds
a page of another session in the notebook. Nothing is copied if the a page of another session in the notebook. Nothing is copied if the
page is blank or an identical copy already exists. page is blank or the same page is already kept in that notebook; a
page loaded from a notebook and left unchanged is therefore not kept
again, although the file names it as a new page. A kept page or a
whole notebook can be deleted from the Notebooks chooser; the local
program moves it to the system's Trash (the freedesktop Trash on
Linux, with the info file the file manager wants for putting it back;
`~/.Trash` on macOS) unless `--trash` says otherwise, and the
browser's notebook, having none, deletes for good.
The file is an SVG document that any viewer displays: the images as The file is an SVG document that any viewer displays: the images as
embedded `<image>` elements, each layer as a group with the participant's embedded `<image>` elements, each layer as a group with the participant's
+10 -7
View File
@@ -55,10 +55,11 @@ on their own computers. The relay server that passes the messages cannot
read them. The invitation to a session carries the key, so only those read them. The invitation to a session carries the key, so only those
who were invited can read the page. who were invited can read the page.
**Nothing to install for guests.** A guest joins by opening a link in a **Nothing to install.** A guest joins by opening a link in a web
web browser. Someone who wants a notebook of real files on their own browser, and a host can start a session from the web site in the same
computer installs the small Kokkoja program instead; that is also how a way. Someone who wants a notebook of real files on their own computer,
host runs Kokkoja. a teacher with a notebook per student for instance, installs the small
Kokkoja program instead.
**Free software.** Kokkoja is free and open-source. Anyone can use it **Free software.** Kokkoja is free and open-source. Anyone can use it
without paying, and nobody depends on a company's service. It runs in without paying, and nobody depends on a company's service. It runs in
@@ -120,11 +121,13 @@ mind before reading on.
the client's files the client's files
``` ```
* **The host** runs the small Kokkoja program on their own computer. * **The host** in the picture runs the small Kokkoja program on their
It shows Kokkoja in the host's browser, at the address own computer. It shows Kokkoja in the host's browser, at the address
`http://localhost:8760/`, and it keeps the host's notebooks as `http://localhost:8760/`, and it keeps the host's notebooks as
directories of files. It must be running for as long as the host is directories of files. It must be running for as long as the host is
in a session; the browser alone cannot reach the notebooks. in a session; the browser alone cannot reach the notebooks. A host
can also do without it and start the session from the web site, like
guest A, with the notebooks in the browser.
* **The server** does two things for everyone. Its *relay* passes the * **The server** does two things for everyone. Its *relay* passes the
participants' messages, which are encrypted before they leave each participants' messages, which are encrypted before they leave each
+9 -4
View File
@@ -10,9 +10,10 @@ works](implementation.md).
Kokkoja has three parts. The **client** is plain JavaScript that runs Kokkoja has three parts. The **client** is plain JavaScript that runs
in a browser. The **local program**, `kokkoja.py`, serves the client to in a browser. The **local program**, `kokkoja.py`, serves the client to
your own browser and keeps your notebooks as directories of SVG files; your own browser and keeps your notebooks as directories of SVG files;
a host needs it, a guest does not. The **relay** passes the without it the notebooks live in the browser's storage. The **relay**
participants' messages, encrypted end to end so that it cannot read passes the participants' messages, encrypted end to end so that it
them, and serves the client to guests as a web site; Andy's relay at cannot read them, and serves the client as a web site, from which anyone
can host or join with nothing installed; Andy's relay at
`kokkoja.andykopra.com` is there for you, and you can run your own. `kokkoja.andykopra.com` is there for you, and you can run your own.
## Requirements ## Requirements
@@ -36,6 +37,10 @@ the directory.
## Host a session ## Host a session
With nothing installed: open `https://kokkoja.andykopra.com/`, enter a
name, press **Start a session**; the notebooks are then in the browser.
With the program, for notebooks as files:
python3 kokkoja.py --relay wss://kokkoja.andykopra.com/relay python3 kokkoja.py --relay wss://kokkoja.andykopra.com/relay
The program prints `http://localhost:8760/#token=…` and opens your The program prints `http://localhost:8760/#token=…` and opens your
@@ -64,7 +69,7 @@ rather than in the browser's storage.
The toolbar holds the pen, text, eraser and hide tools, colours and The toolbar holds the pen, text, eraser and hide tools, colours and
line widths, the view commands (Fit, A4, Widen, Shrink, Extend), the line widths, the view commands (Fit, A4, Widen, Shrink, Extend), the
host's page buttons (New, Image, Notebook), your notebook, Keep, Export host's page buttons (New, Image, Notebooks), your notebook, Keep, Export
and Session. Rest the pointer on any button for two seconds for its and Session. Rest the pointer on any button for two seconds for its
explanation. Each participant writes on a layer of their own that explanation. Each participant writes on a layer of their own that
nobody else can change. The page is saved continuously to the current nobody else can change. The page is saved continuously to the current
+125 -6
View File
@@ -27,6 +27,7 @@ import os
import pathlib import pathlib
import re import re
import secrets import secrets
import shutil
import subprocess import subprocess
import sys import sys
import threading import threading
@@ -42,6 +43,22 @@ MAX_PAGE_BYTES = 64 << 20
CURRENT_PAGE = "Current.svg" CURRENT_PAGE = "Current.svg"
KEPT_PAGE = re.compile(r"^(\d{4}_\d{2}_\d{2})_(\d{2,})\.svg$") KEPT_PAGE = re.compile(r"^(\d{4}_\d{2}_\d{2})_(\d{2,})\.svg$")
# What names a page file rather than describes the page: the session,
# the host, the page's id and number on the root element, and the title
# that repeats the number. A page loaded from a notebook is a new page
# with new ones of these, so two files of the same page differ here and
# nowhere else (2026-10-11, after loading a kept page and leaving it
# unchanged kept it again at every change of page). They stand before
# the paper's <rect>, and only there; typed text (in <desc>) may contain
# anything, so the search stops at the paper.
VOLATILE = re.compile(rb' data-(?:session|host|page|number)="[^"]*"|<title>[^<]*</title>\n?')
def page_content(data):
"""The page file without what names it, for telling the same page in two files."""
head, paper, rest = data.partition(b"<rect ")
return VOLATILE.sub(b"", head) + paper + rest
# Notebook files are changed by one request at a time. # Notebook files are changed by one request at a time.
notebook_lock = threading.Lock() notebook_lock = threading.Lock()
@@ -89,6 +106,72 @@ def default_notebooks_directory():
return documents / "Kokkoja" return documents / "Kokkoja"
class Trash:
"""Where deleted notebooks and pages go, so that a deletion can be
undone in the file manager (Andy, 2026-10-11: the Trash if
possible). Two layouts: the freedesktop Trash on Linux, which wants
an info file beside each entry, and a plain directory, which is what
macOS's Trash is to a program and what --trash names.
"""
def __init__(self, directory, freedesktop=False):
self.directory = pathlib.Path(directory)
self.freedesktop = freedesktop
@classmethod
def system(cls):
"""The system's Trash, or None where this program knows none
(Windows: the Recycle Bin needs the shell's help)."""
if sys.platform == "darwin":
return cls(pathlib.Path.home() / ".Trash")
if sys.platform.startswith("linux"):
base = os.environ.get("XDG_DATA_HOME") or pathlib.Path.home() / ".local" / "share"
return cls(pathlib.Path(base) / "Trash", freedesktop=True)
return None
def take(self, path):
"""Move a file or directory into the Trash under a name not
yet taken there. Raises OSError if that cannot be done; nothing is
deleted then."""
files = self.directory / "files" if self.freedesktop else self.directory
files.mkdir(parents=True, exist_ok=True)
if self.freedesktop:
info = self.directory / "info"
info.mkdir(parents=True, exist_ok=True)
self.directory.chmod(0o700)
for serial in range(1, 1000):
name = path.name if serial == 1 else f"{path.stem}.{serial}{path.suffix}"
if self.freedesktop:
# The info file reserves the name (the spec's way), and
# tells the file manager where the entry came from.
try:
descriptor = os.open(info / f"{name}.trashinfo", os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError:
continue
with os.fdopen(descriptor, "w") as file:
file.write("[Trash Info]\n"
f"Path={urllib.parse.quote(str(path.resolve()))}\n"
f"DeletionDate={time.strftime('%Y-%m-%dT%H:%M:%S')}\n")
elif (files / name).exists():
continue
shutil.move(str(path), str(files / name))
return
raise OSError(f"no free name for {path.name} in the Trash")
def discard(path, trash):
"""Move a notebook or a page to the Trash, or delete it outright
where there is none. Returns whether the Trash took it."""
if trash is not None:
trash.take(path)
return True
if path.is_dir():
shutil.rmtree(path)
else:
path.unlink()
return False
def load_token(directory): def load_token(directory):
"""The secret token, made on first use and kept in a private file.""" """The secret token, made on first use and kept in a private file."""
path = directory / "token" path = directory / "token"
@@ -146,7 +229,8 @@ def keep_page(notebook):
The copy is named for the day the page was last changed, with a serial The copy is named for the day the page was last changed, with a serial
number: 2026_10_02_01.svg. Nothing is copied if the page is blank or number: 2026_10_02_01.svg. Nothing is copied if the page is blank or
if an identical page is already kept. if the same page is already kept (the same but for what names the
file: see page_content).
Returns (name, new): the name of the kept page, or None if there was Returns (name, new): the name of the kept page, or None if there was
nothing to keep, and whether the copy was made just now. nothing to keep, and whether the copy was made just now.
@@ -163,8 +247,12 @@ def keep_page(notebook):
kept = [entry for entry in notebook.iterdir() kept = [entry for entry in notebook.iterdir()
if KEPT_PAGE.match(entry.name) and entry.is_file()] if KEPT_PAGE.match(entry.name) and entry.is_file()]
content = page_content(data)
for entry in kept: for entry in kept:
if entry.stat().st_size == len(data) and entry.read_bytes() == data: # The ids are of fixed length, so files of the same page differ
# in size by the digits of the page number at most; a notebook
# of large pages is not read through for every keep.
if abs(entry.stat().st_size - len(data)) <= 32 and page_content(entry.read_bytes()) == content:
return entry.name, False return entry.name, False
date = time.strftime("%Y_%m_%d", time.localtime(changed)) date = time.strftime("%Y_%m_%d", time.localtime(changed))
@@ -256,6 +344,9 @@ class Handler(http.server.SimpleHTTPRequestHandler):
def do_POST(self): def do_POST(self):
self.notebook_request("POST", self.path.split("?")[0]) self.notebook_request("POST", self.path.split("?")[0])
def do_DELETE(self):
self.notebook_request("DELETE", self.path.split("?")[0])
def allowed(self): def allowed(self):
"""Whether a notebook request comes from the user's own browser.""" """Whether a notebook request comes from the user's own browser."""
if not LOCAL_HOST.match(self.headers.get("Host", "")): if not LOCAL_HOST.match(self.headers.get("Host", "")):
@@ -316,6 +407,12 @@ class Handler(http.server.SimpleHTTPRequestHandler):
GET /api/notebooks/<notebook>/<page> a page GET /api/notebooks/<notebook>/<page> a page
PUT /api/notebooks/<notebook>/<page> write a page PUT /api/notebooks/<notebook>/<page> write a page
POST /api/notebooks/<notebook>/keep keep a copy of Current.svg POST /api/notebooks/<notebook>/keep keep a copy of Current.svg
DELETE /api/notebooks/<notebook> the notebook, pages and all
DELETE /api/notebooks/<notebook>/<page> a kept page
A deletion moves the notebook or page to the Trash when the
program has one; the listing of the notebooks says whether it has
("trash"), so that the client can say which it will be.
""" """
if not self.allowed(): if not self.allowed():
return return
@@ -337,6 +434,16 @@ class Handler(http.server.SimpleHTTPRequestHandler):
self.send_json({"kept": name, "new": new}) self.send_json({"kept": name, "new": new})
elif method == "POST": elif method == "POST":
self.refuse(405, "nothing to do") self.refuse(405, "nothing to do")
elif method == "DELETE":
if not names:
return self.refuse(405, "the notebooks directory stays")
if len(names) == 2 and names[1] == CURRENT_PAGE:
return self.refuse(400, "the current page is being written")
with notebook_lock:
if not target.exists():
return self.refuse(404, "not found")
basket = discard(target, self.trash)
self.send_json({"discarded": names[-1], "trash": basket})
elif method == "PUT": elif method == "PUT":
if len(names) != 2: if len(names) != 2:
return self.refuse(405, "only a page can be written") return self.refuse(405, "only a page can be written")
@@ -350,7 +457,8 @@ class Handler(http.server.SimpleHTTPRequestHandler):
self.send_json({"saved": names[1]}) self.send_json({"saved": names[1]})
elif len(names) == 0: elif len(names) == 0:
self.send_json({"directory": str(self.notebooks), self.send_json({"directory": str(self.notebooks),
"notebooks": self.listing(self.notebooks, directories=True)}) "notebooks": self.listing(self.notebooks, directories=True),
"trash": self.trash is not None})
elif len(names) == 1: elif len(names) == 1:
self.send_json({"pages": self.listing(target, directories=False)}) self.send_json({"pages": self.listing(target, directories=False)})
else: else:
@@ -474,10 +582,13 @@ class Handler(http.server.SimpleHTTPRequestHandler):
(entry.is_dir() if directories else entry.is_file() and entry.name.endswith(".svg"))) (entry.is_dir() if directories else entry.is_file() and entry.name.endswith(".svg")))
def make_server(port, relay, notebooks, token, config, site=""): def make_server(port, relay, notebooks, token, config, site="", trash=None):
"""trash: a Trash for deleted notebooks and pages, or None
to delete them outright."""
handler = type("ConfiguredHandler", (Handler,), { handler = type("ConfiguredHandler", (Handler,), {
"relay": relay.rstrip("/"), "site": site_of(relay, site), "token": token, "relay": relay.rstrip("/"), "site": site_of(relay, site), "token": token,
"notebooks": pathlib.Path(notebooks), "config": pathlib.Path(config)}) "notebooks": pathlib.Path(notebooks), "config": pathlib.Path(config),
"trash": trash})
handler = functools.partial(handler, directory=str(CLIENT_DIRECTORY)) handler = functools.partial(handler, directory=str(CLIENT_DIRECTORY))
return http.server.ThreadingHTTPServer(("127.0.0.1", port), handler) return http.server.ThreadingHTTPServer(("127.0.0.1", port), handler)
@@ -499,6 +610,11 @@ def main():
parser.add_argument("--config", type=pathlib.Path, default=None, parser.add_argument("--config", type=pathlib.Path, default=None,
help="directory for Kokkoja's own settings " help="directory for Kokkoja's own settings "
"(default: your system's usual place)") "(default: your system's usual place)")
parser.add_argument("--trash", default="",
help="where deleted notebooks and pages are moved: a "
"directory, or 'none' to delete them outright; by "
"default the system's Trash, or "
"outright where the program knows none")
parser.add_argument("--no-browser", action="store_true", parser.add_argument("--no-browser", action="store_true",
help="do not open a browser window") help="do not open a browser window")
arguments = parser.parse_args() arguments = parser.parse_args()
@@ -511,7 +627,10 @@ def main():
" python3 kokkoja.py --relay wss://kokkoja.example/relay\n" " python3 kokkoja.py --relay wss://kokkoja.example/relay\n"
"Afterwards the address is remembered and python3 kokkoja.py is enough.\n") "Afterwards the address is remembered and python3 kokkoja.py is enough.\n")
token = load_token(config) token = load_token(config)
server = make_server(arguments.port, relay, notebooks, token, config, arguments.site) trash = (None if arguments.trash == "none"
else Trash(arguments.trash) if arguments.trash
else Trash.system())
server = make_server(arguments.port, relay, notebooks, token, config, arguments.site, trash)
address = f"http://localhost:{arguments.port}/#token={token}" address = f"http://localhost:{arguments.port}/#token={token}"
print(f"Kokkoja is at {address}", flush=True) print(f"Kokkoja is at {address}", flush=True)
print(f"Relay: {relay}" + (" (remembered from last time)" if remembered else ""), flush=True) print(f"Relay: {relay}" + (" (remembered from last time)" if remembered else ""), flush=True)
+95 -3
View File
@@ -521,6 +521,8 @@ class EndToEndTest(unittest.TestCase):
cls.processes = [] cls.processes = []
cls.directory = tempfile.TemporaryDirectory() cls.directory = tempfile.TemporaryDirectory()
cls.notebooks = pathlib.Path(cls.directory.name) / "notebooks" cls.notebooks = pathlib.Path(cls.directory.name) / "notebooks"
# Deleted notebooks and pages go here, not to the real Trash.
cls.trash = pathlib.Path(cls.directory.name) / "trash"
relay_port, driver_port = free_port(), free_port() relay_port, driver_port = free_port(), free_port()
cls.start([sys.executable, str(PROJECT / "relay" / "kokkoja_relay.py"), cls.start([sys.executable, str(PROJECT / "relay" / "kokkoja_relay.py"),
"--port", str(relay_port)]) "--port", str(relay_port)])
@@ -552,7 +554,8 @@ class EndToEndTest(unittest.TestCase):
site_option = ["--site", cls.site] if name == "Host" else [] site_option = ["--site", cls.site] if name == "Host" else []
cls.start([sys.executable, str(PROJECT / "kokkoja.py"), "--no-browser", cls.start([sys.executable, str(PROJECT / "kokkoja.py"), "--no-browser",
"--port", str(port), "--relay", f"ws://127.0.0.1:{relay_port}", "--port", str(port), "--relay", f"ws://127.0.0.1:{relay_port}",
"--notebooks", str(cls.notebooks), "--config", str(config), *site_option]) "--notebooks", str(cls.notebooks), "--config", str(config),
"--trash", str(cls.trash), *site_option])
cls.programs[name] = (f"http://{host}:{port}/", config) cls.programs[name] = (f"http://{host}:{port}/", config)
cls.start(["geckodriver", "--port", str(driver_port)]) cls.start(["geckodriver", "--port", str(driver_port)])
try: try:
@@ -824,6 +827,21 @@ class EndToEndTest(unittest.TestCase):
lambda v: v.startswith("Already kept"), "the second keep to be reported") lambda v: v.startswith("Already kept"), "the second keep to be reported")
time.sleep(1) time.sleep(1)
self.assertEqual(sorted(p.name for p in self.downloads.iterdir()), [f"{today}_01.svg"]) self.assertEqual(sorted(p.name for p in self.downloads.iterdir()), [f"{today}_01.svg"])
# The browser has no trash: a deleted page is gone, and the
# notebook with it when it is deleted whole.
self.assertEqual(firefox.run_until_done("""
const [name, done] = arguments;
(async () => {
const m = window.kokkoja.notebook;
const listed = await m.listNotebooks();
await m.discardPage('Guest notebook', name);
const after = await m.listKeptPages('Guest notebook');
await m.discardNotebook('Guest notebook');
done({trash: listed.trash, after,
notebooks: (await m.listNotebooks()).notebooks,
current: await m.readCurrentPage('Guest notebook')});
})().catch((e) => done({error: String(e)}));
""", f"{today}_01.svg"), {"trash": False, "after": [], "notebooks": [], "current": None})
# Exporting downloads a picture. # Exporting downloads a picture.
firefox.run("document.getElementById('export_page').click();") firefox.run("document.getElementById('export_page').click();")
@@ -2072,11 +2090,11 @@ class EndToEndTest(unittest.TestCase):
# is the student's again. # is the student's again.
firefox.run("document.getElementById('load_notebook_page').click();") firefox.run("document.getElementById('load_notebook_page').click();")
notebooks = self.wait_until_true(lambda: firefox.run(""" notebooks = self.wait_until_true(lambda: firefox.run("""
return [...document.querySelectorAll('#chooser_notebooks button')].map((b) => b.textContent); return [...document.querySelectorAll('#chooser_notebooks button.notebook_pick')].map((b) => b.textContent);
"""), lambda names: "Teacher notebook" in names, "the chooser to list the notebooks") """), lambda names: "Teacher notebook" in names, "the chooser to list the notebooks")
self.assertEqual(notebooks, sorted(notebooks)) self.assertEqual(notebooks, sorted(notebooks))
firefox.run(""" firefox.run("""
[...document.querySelectorAll('#chooser_notebooks button')] [...document.querySelectorAll('#chooser_notebooks button.notebook_pick')]
.find((b) => b.textContent === 'Teacher notebook').click(); .find((b) => b.textContent === 'Teacher notebook').click();
""") """)
cards = self.wait_until_true(lambda: firefox.run(""" cards = self.wait_until_true(lambda: firefox.run("""
@@ -2140,6 +2158,80 @@ class EndToEndTest(unittest.TestCase):
"""), lambda n: n >= count, "the chooser to list the kept pages again") """), lambda n: n >= count, "the chooser to list the kept pages again")
self.assertEqual(loaded["images"], stacked["images"]) self.assertEqual(loaded["images"], stacked["images"])
self.assertEqual(loaded["page"]["height"], 3608) self.assertEqual(loaded["page"]["height"], 3608)
# Each page loaded on the way was left unchanged, so none was kept
# again (the file names a page anew at every load; the contents
# are compared without the names).
self.wait_until_true(
lambda: (self.notebooks / "Teacher notebook" / "Current.svg").read_text(),
lambda text: f'data-number="{loaded["page"]["number"]}"' in text, "the loaded page to be written")
self.assertEqual(len(self.kept_pages("Teacher notebook")), count)
# Deleting from the chooser: a mark on each page and notebook, one
# Delete button that asks for a second press, the entries moved
# to the Trash. The notebook being written in cannot be
# marked; another notebook goes with everything in it.
firefox.run("document.getElementById('load_notebook_page').click();")
self.wait_until_true(lambda: firefox.run("""
return document.querySelectorAll('#chooser_pages .page_slot button.mark').length;
"""), lambda n: n == count, "the chooser to list the kept pages with marks")
self.assertTrue(firefox.run("return document.getElementById('chooser_delete').hidden;"))
before = self.kept_pages("Teacher notebook")
firefox.run("""
const marks = document.querySelectorAll('#chooser_pages .page_slot button.mark');
marks[0].click(); marks[1].click(); marks[2].click(); marks[2].click();
""")
delete = lambda: firefox.run("""
const b = document.getElementById('chooser_delete');
return b.hidden ? null : b.textContent;
""")
self.assertEqual(delete(), "Delete 2 pages?")
self.assertEqual(firefox.run("""
return [...document.querySelectorAll('#chooser_pages .page_slot')].map((s) => s.classList.contains('marked'));
""")[:3], [True, True, False])
firefox.run("document.getElementById('chooser_delete').click();")
self.assertEqual(delete(), "Press again to move 2 pages to the Trash")
# Marking something else takes the first press back.
firefox.run("document.querySelectorAll('#chooser_pages .page_slot button.mark')[2].click();")
self.assertEqual(delete(), "Delete 3 pages?")
firefox.run("document.getElementById('chooser_delete').click();")
firefox.run("document.getElementById('chooser_delete').click();")
self.wait_until_true(lambda: firefox.run("return document.getElementById('chooser_message').textContent;"),
lambda text: text == "3 pages moved to the Trash.", "the deletion to be reported")
self.assertEqual(self.kept_pages("Teacher notebook"), before[3:])
self.assertEqual(sorted(p.name for p in self.trash.iterdir()), before[:3])
self.assertEqual(firefox.run("return document.querySelectorAll('#chooser_pages .page_slot').length;"),
count - 3)
self.assertIsNone(delete())
# The notebook being written in: its mark explains instead.
firefox.run("""
[...document.querySelectorAll('#chooser_notebooks .notebook_row')]
.find((r) => r.querySelector('.notebook_pick').textContent === 'Teacher notebook')
.querySelector('button.mark').click();
""")
self.assertIn("writing in this notebook", firefox.run("return document.getElementById('chooser_message').textContent;"))
self.assertIsNone(delete())
# Another notebook, with its pages, goes as one; the toolbar's list loses it.
self.assertTrue((self.notebooks / "Other notebook" / "Current.svg").exists())
firefox.run("""
[...document.querySelectorAll('#chooser_notebooks .notebook_row')]
.find((r) => r.querySelector('.notebook_pick').textContent === 'Other notebook')
.querySelector('button.mark').click();
""")
self.assertEqual(delete(), "Delete 1 notebook?")
firefox.run("document.getElementById('chooser_delete').click();")
self.assertEqual(delete(), "Press again to move 1 notebook to the Trash")
firefox.run("document.getElementById('chooser_delete').click();")
self.wait_until_true(lambda: firefox.run("return document.getElementById('chooser_message').textContent;"),
lambda text: text == "1 notebook moved to the Trash.", "the notebook's deletion to be reported")
self.assertFalse((self.notebooks / "Other notebook").exists())
self.assertTrue((self.trash / "Other notebook" / "Current.svg").exists())
self.assertNotIn("Other notebook", firefox.run("""
return [...document.querySelectorAll('#chooser_notebooks button.notebook_pick')].map((b) => b.textContent);
"""))
self.wait_until_true(lambda: firefox.run("""
return [...document.getElementById('session_notebook').options].map((o) => o.textContent);
"""), lambda names: "Other notebook" not in names, "the toolbar's list to lose the notebook")
firefox.run("document.getElementById('chooser_close').click();")
# A page that its images have made longer than a sheet is first # A page that its images have made longer than a sheet is first
# shown at the scale at which one sheet fits the window. # shown at the scale at which one sheet fits the window.
self.assertEqual(firefox.run(VIEW), long_view) self.assertEqual(firefox.run(VIEW), long_view)
+93 -1
View File
@@ -28,14 +28,29 @@ TEXT_ONLY_LAYER = (b'<g data-layer="abc" data-name="Andy"><g data-text="t1" data
WRITTEN_PAGE = b'<svg data-kokkoja="1"><g data-layer="a"><g data-stroke="s"/></g></svg>\n' WRITTEN_PAGE = b'<svg data-kokkoja="1"><g data-layer="a"><g data-stroke="s"/></g></svg>\n'
def page_named(session, page, number, body=b'<g data-layer="a"><g data-stroke="s"/></g>'):
"""A page file as the client writes it: named on the root element and
in the title, then the paper and the content."""
return (b'<?xml version="1.0" encoding="UTF-8"?>\n'
b'<svg xmlns="http://www.w3.org/2000/svg" data-kokkoja="1" data-session="' + session +
b'" data-host="h" data-page="' + page + b'" data-number="' + number +
b'" data-base="2970 2100" viewBox="0 0 2970 2100">\n'
b'<title>Kokkoja page ' + number + b'</title>\n'
b'<rect x="0" y="0" width="2970" height="2100" fill="#ffffff"/>\n' + body + b'</svg>\n')
class LocalProgramTest(unittest.TestCase): class LocalProgramTest(unittest.TestCase):
def setUp(self): def setUp(self):
self.directory = tempfile.TemporaryDirectory() self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup) self.addCleanup(self.directory.cleanup)
self.notebooks = pathlib.Path(self.directory.name) / "notebooks" self.notebooks = pathlib.Path(self.directory.name) / "notebooks"
self.config = pathlib.Path(self.directory.name) / "config" self.config = pathlib.Path(self.directory.name) / "config"
# Deleted things go to a Trash of the test's own, a plain
# directory as macOS's Trash is; the freedesktop layout has its
# own test.
self.trash = pathlib.Path(self.directory.name) / "trash"
self.server = kokkoja.make_server(0, "ws://relay.example:8770/", self.notebooks, TOKEN, self.server = kokkoja.make_server(0, "ws://relay.example:8770/", self.notebooks, TOKEN,
self.config) self.config, trash=kokkoja.Trash(self.trash))
self.port = self.server.server_address[1] self.port = self.server.server_address[1]
thread = threading.Thread(target=self.server.serve_forever, daemon=True) thread = threading.Thread(target=self.server.serve_forever, daemon=True)
thread.start() thread.start()
@@ -166,6 +181,83 @@ class LocalProgramTest(unittest.TestCase):
self.request("PUT", current, body=b'<svg data-kokkoja="1">' + TEXT_ONLY_LAYER + b"</svg>\n") self.request("PUT", current, body=b'<svg data-kokkoja="1">' + TEXT_ONLY_LAYER + b"</svg>\n")
self.assertEqual(self.json("POST", keep), (200, {"kept": f"{today}_03.svg", "new": True})) self.assertEqual(self.json("POST", keep), (200, {"kept": f"{today}_03.svg", "new": True}))
# The same page under other names is the same page: loaded from
# the notebook into another session, it is a new page with a new
# id and number, and is not kept again when left unchanged. A
# different sheet, or typed text that happens to name a page, is
# not the same.
self.request("PUT", current, body=page_named(b"s1", b"p1", b"4"))
self.assertEqual(self.json("POST", keep), (200, {"kept": f"{today}_04.svg", "new": True}))
self.request("PUT", current, body=page_named(b"s2", b"p2", b"11"))
self.assertEqual(self.json("POST", keep), (200, {"kept": f"{today}_04.svg", "new": False}))
self.request("PUT", current, body=page_named(b"s2", b"p2", b"11").replace(b'viewBox="0 0', b'viewBox="-50 0'))
self.assertEqual(self.json("POST", keep), (200, {"kept": f"{today}_05.svg", "new": True}))
typed = page_named(b"s1", b"p1", b"4", body=b'<g data-layer="a"><g data-text="t"><desc>data-page="p1"</desc></g></g>')
self.request("PUT", current, body=typed)
self.assertEqual(self.json("POST", keep), (200, {"kept": f"{today}_06.svg", "new": True}))
self.request("PUT", current, body=typed.replace(b'<desc>data-page="p1"', b'<desc>data-page="p2"'))
self.assertEqual(self.json("POST", keep), (200, {"kept": f"{today}_07.svg", "new": True}))
def test_deleting_pages_and_notebooks(self):
notebook = self.notebooks / "Andy"
notebook.mkdir(parents=True)
for name in ("Current.svg", "2026_10_01_01.svg", "2026_10_01_02.svg"):
(notebook / name).write_bytes(WRITTEN_PAGE)
(self.notebooks / "Other").mkdir()
(self.notebooks / "Other" / "2026_10_02_01.svg").write_bytes(WRITTEN_PAGE)
self.assertTrue(self.json("GET", "/api/notebooks")[1]["trash"])
# A kept page goes to the Trash; the page being written stays.
self.assertEqual(self.json("DELETE", "/api/notebooks/Andy/2026_10_01_01.svg"),
(200, {"discarded": "2026_10_01_01.svg", "trash": True}))
self.assertEqual(self.json("GET", "/api/notebooks/Andy")[1]["pages"], ["2026_10_01_02.svg", "Current.svg"])
self.assertEqual((self.trash / "2026_10_01_01.svg").read_bytes(), WRITTEN_PAGE)
self.assertEqual(self.request("DELETE", "/api/notebooks/Andy/Current.svg")[0], 400)
self.assertEqual(self.request("DELETE", "/api/notebooks/Andy/none.svg")[0], 404)
self.assertEqual(self.request("DELETE", "/api/notebooks")[0], 405)
self.assertEqual(self.request("DELETE", "/api/notebooks/Andy/2026_10_01_02.svg", token=None)[0], 401)
# A whole notebook goes as one, and a name already taken in the
# Trash is not overwritten.
(self.notebooks / "Andy" / "2026_10_01_01.svg").write_bytes(b"kept again")
self.assertEqual(self.json("DELETE", "/api/notebooks/Andy"),
(200, {"discarded": "Andy", "trash": True}))
self.assertEqual(self.json("GET", "/api/notebooks")[1]["notebooks"], ["Other"])
self.assertEqual(sorted(p.name for p in (self.trash / "Andy").iterdir()),
["2026_10_01_01.svg", "2026_10_01_02.svg", "Current.svg"])
(self.notebooks / "Andy").mkdir()
(self.notebooks / "Andy" / "Current.svg").write_bytes(b"a second Andy")
self.assertEqual(self.json("DELETE", "/api/notebooks/Andy")[1]["discarded"], "Andy")
self.assertEqual((self.trash / "Andy.2" / "Current.svg").read_bytes(), b"a second Andy")
self.assertEqual((self.trash / "2026_10_01_01.svg").read_bytes(), WRITTEN_PAGE)
def test_the_freedesktop_trash(self):
basket = pathlib.Path(self.directory.name) / "Trash"
trash = kokkoja.Trash(basket, freedesktop=True)
page = self.notebooks / "Andy" / "2026_10_01_01.svg"
page.parent.mkdir(parents=True)
page.write_bytes(WRITTEN_PAGE)
trash.take(page)
self.assertFalse(page.exists())
self.assertEqual((basket / "files" / page.name).read_bytes(), WRITTEN_PAGE)
info = (basket / "info" / f"{page.name}.trashinfo").read_text()
self.assertTrue(info.startswith("[Trash Info]\nPath="), info)
self.assertIn(f"Path={urllib.parse.quote(str(page.resolve()))}\n", info)
self.assertRegex(info, r"DeletionDate=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\n")
self.assertEqual(basket.stat().st_mode & 0o777, 0o700)
# The same name again: the info file reserves the first, so the
# second gets a serial.
page.write_bytes(b"another")
trash.take(page)
self.assertEqual((basket / "files" / "2026_10_01_01.2.svg").read_bytes(), b"another")
self.assertTrue((basket / "info" / "2026_10_01_01.2.svg.trashinfo").exists())
# Without a Trash, a deletion is for good.
page.write_bytes(b"gone")
self.assertFalse(kokkoja.discard(page, None))
self.assertFalse(page.exists())
self.assertTrue(kokkoja.discard(page.parent, kokkoja.Trash(basket)))
self.assertTrue((basket / "Andy").is_dir())
def test_a_kept_page_is_dated_when_it_was_last_changed(self): def test_a_kept_page_is_dated_when_it_was_last_changed(self):
notebook = self.notebooks / "Andy" notebook = self.notebooks / "Andy"
notebook.mkdir(parents=True) notebook.mkdir(parents=True)