diff --git a/client/js/browser_notebook.js b/client/js/browser_notebook.js
index 7364524..8809749 100644
--- a/client/js/browser_notebook.js
+++ b/client/js/browser_notebook.js
@@ -17,6 +17,26 @@ const PAGES = 'pages';
const IDENTITY_KEY = 'kokkoja_identity';
const CURRENT_PAGE = 'Current.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
, 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>\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(' notebook))].sort();
});
- return { directory: '', notebooks: names };
+ return { directory: '', notebooks: names, trash: false };
},
async listPages(notebook) {
@@ -132,15 +152,17 @@ export const backend = {
},
// 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
- // last changed and a serial number.
+ // blank page or of one already kept (the same page, whatever names the
+ // file); the name is the day the page was last changed and a serial
+ // number.
async keepPage(notebook) {
return transaction('readwrite', async (pages) => {
const current = /** @type {StoredPage | undefined} */ (await result(pages.get([notebook, CURRENT_PAGE])));
const blank = !['data-stroke=', 'data-text=', ' current?.text.includes(mark));
if (!current || blank) return { kept: null, new: false };
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 };
const day = dayOf(current.changed);
const serials = kept.map((page) => /** @type {RegExpExecArray} */ (KEPT_PAGE.exec(page.name)))
@@ -150,4 +172,15 @@ export const backend = {
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])); });
+ },
};
diff --git a/client/js/chooser.js b/client/js/chooser.js
index 61e72be..80b5fec 100644
--- a/client/js/chooser.js
+++ b/client/js/chooser.js
@@ -2,9 +2,19 @@
/**
* The notebook chooser: the "Notebook" button's dialog, which shows the
* 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
@@ -23,35 +33,80 @@ function pageTitle(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 {
/**
* @param {(notebook: string, pageName: string, text: string) => void} onChosen
* 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.onDeleted = onDeleted;
this.dialog = /** @type {HTMLDialogElement} */ (element('notebook_chooser'));
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. */
this.opening = 0;
/** @type {string[]} */
this.pictureAddresses = [];
+ /** @type {Set} Notebooks marked for deletion. */
+ this.markedNotebooks = new Set();
+ /** @type {Map>} 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());
- 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.
- * @param {string} [notebook] Open on this notebook.
+ * @param {string} [notebook] Open on this notebook: the one being written in.
*/
async open(notebook) {
this.opening += 1;
+ this.writing = notebook ?? '';
+ this.clearMarks();
this.dialog.showModal();
element('chooser_message').textContent = '';
element('chooser_pages').replaceChildren(); // Nothing stale while the lists load.
try {
- const { notebooks } = await listNotebooks();
+ const { notebooks, trash } = await listNotebooks();
+ this.trash = trash;
this.showNotebooks(notebooks);
// The notebook being written in is chosen already, its pages shown;
// the list is always there to see (Andy, 2026-10-06).
@@ -59,44 +114,69 @@ export class NotebookChooser {
: notebooks.length === 1 ? notebooks[0] : '';
await this.showPages(chosen);
} 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 */
showNotebooks(notebooks) {
const list = element('chooser_notebooks');
list.replaceChildren();
for (const notebook of notebooks) {
+ const row = document.createElement('div');
+ row.className = 'notebook_row';
const button = document.createElement('button');
button.type = 'button';
+ button.className = 'notebook_pick';
button.textContent = 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 */
async showPages(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));
}
const grid = element('chooser_pages');
grid.replaceChildren();
this.forgetPictures();
if (!notebook) {
- element('chooser_message').textContent = 'Choose a notebook.';
+ this.say('Choose a notebook.');
return;
}
const opening = this.opening += 1;
const pages = await listKeptPages(notebook);
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} */
const pictures = new Map();
for (const pageName of pages) {
+ const slot = document.createElement('div');
+ slot.className = 'page_slot';
const card = document.createElement('button');
card.type = 'button';
card.className = 'page_card';
@@ -112,8 +192,17 @@ export class NotebookChooser {
this.dialog.close();
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.
for (const [pageName, picture] of pictures) {
@@ -126,6 +215,100 @@ export class NotebookChooser {
}
}
+ /**
+ * Mark or unmark one entry.
+ * @param {HTMLButtonElement} mark
+ * @param {Set} 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() {
for (const address of this.pictureAddresses) URL.revokeObjectURL(address);
this.pictureAddresses = [];
diff --git a/client/js/main.js b/client/js/main.js
index f1590c6..0edf2fa 100644
--- a/client/js/main.js
+++ b/client/js/main.js
@@ -9,7 +9,7 @@ import {
parseInvitation, saveIdentity,
} from './crypto.js';
import {
- NotebookWriter, keepCurrentPage, keepIdentity, listKeptPages, listNotebooks, notebooksInBrowser,
+ NotebookWriter, discardNotebook, discardPage, keepCurrentPage, keepIdentity, listKeptPages, listNotebooks, notebooksInBrowser,
readCurrentPage, readIdentity, readPage, rememberToken, useBrowserStorage,
} from './notebook.js';
import { download, pickImageFile } from './browser_files.js';
@@ -1091,6 +1091,9 @@ async function enterSession(invitationText, name, notebook) {
}
log(`Notebook page loaded: ${chosenNotebook} / ${pageName}`);
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); });
@@ -1218,7 +1221,7 @@ async function enterSession(invitationText, name, notebook) {
// For looking at the session from the browser's console during development.
Object.assign(window, { kokkoja: {
session, renderer, writer, loadImage, textEditor,
- notebook: { listNotebooks, listKeptPages, readPage, readCurrentPage, notebooksInBrowser },
+ notebook: { listNotebooks, listKeptPages, readPage, readCurrentPage, notebooksInBrowser, discardNotebook, discardPage },
} });
}
diff --git a/client/js/notebook.js b/client/js/notebook.js
index 9aa316a..19762d3 100644
--- a/client/js/notebook.js
+++ b/client/js/notebook.js
@@ -34,12 +34,15 @@ const RETRY_MS = 3000;
* @typedef {object} Backend
* @property {() => Promise} readIdentity
* @property {(text: string) => Promise} keepIdentity
- * @property {() => Promise<{directory: string, notebooks: string[]}>} listNotebooks
- * The directory is '' when the notebooks are in the browser.
+ * @property {() => Promise<{directory: string, notebooks: string[], trash: boolean}>} listNotebooks
+ * The directory is '' when the notebooks are in the browser; trash
+ * says whether a deleted notebook or page goes to one, or is gone for good.
* @property {(notebook: string) => Promise} listPages Every page file, Current.svg too.
* @property {(notebook: string, pageName: string) => Promise} readPage
* @property {(notebook: string, pageName: string, text: string) => Promise} writePage
* @property {(notebook: string) => Promise<{kept: string | null, new: boolean}>} keepPage
+ * @property {(notebook: string) => Promise} discardNotebook Delete a notebook, pages and all.
+ * @property {(notebook: string, pageName: string) => Promise} 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}).`);
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;
@@ -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() {
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}
+ */
+export function discardNotebook(notebook) {
+ return backend.discardNotebook(notebook);
+}
+
+/**
+ * Delete a kept page, likewise.
+ * @param {string} notebook
+ * @param {string} pageName
+ * @returns {Promise}
+ */
+export function discardPage(notebook, pageName) {
+ return backend.discardPage(notebook, pageName);
+}
+
/**
* @param {string} notebook
* @returns {Promise} The names of the pages kept in the
diff --git a/client/kokkoja.css b/client/kokkoja.css
index 3002052..e90b468 100644
--- a/client/kokkoja.css
+++ b/client/kokkoja.css
@@ -404,6 +404,23 @@ h2 {
.chooser header { display: flex; align-items: center; }
.chooser header h2 { flex: 1; margin: 0; }
.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 {
display: flex;
@@ -420,7 +437,29 @@ h2 {
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 {
display: grid;
diff --git a/docs/guest.md b/docs/guest.md
index 18ff234..6cd2976 100644
--- a/docs/guest.md
+++ b/docs/guest.md
@@ -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,
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)
-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
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;
one wider than the page widens it. To put an image on a page of its
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
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
open a rectangle and whatever lies under it, writing included, is
covered in white, marked by blue corners so that nobody writes there
diff --git a/docs/host.md b/docs/host.md
index 2c44091..6100aaa 100644
--- a/docs/host.md
+++ b/docs/host.md
@@ -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,
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
-too; this part describes what is added, and how to install the Kokkoja
-program, which a host needs.
+too; this part describes what is added.
+
+## 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
-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:
* **Linux or macOS.** Windows has not been tried yet.
@@ -97,6 +112,8 @@ Other options, all optional:
yourself.
* `--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.
+* `--trash DIRECTORY` is where deleted notebooks and pages are moved,
+ instead of your system's Trash; `--trash none` deletes them outright.
## 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
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 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
@@ -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.
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**
-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
@@ -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
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
-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
on its own, or to add a finished page to a notebook of exercises.
diff --git a/docs/implementation.md b/docs/implementation.md
index 4b25f76..11fa090 100644
--- a/docs/implementation.md
+++ b/docs/implementation.md
@@ -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.
It is Python with one library, `websockets`.
* **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
- program. `deploy/` has the server setup.
+ `config.json` beside it, so that a guest can join, and a host can
+ start a session, without the local program. `deploy/` has the server
+ setup.
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
@@ -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
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
-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
embedded `` elements, each layer as a group with the participant's
diff --git a/docs/introduction.md b/docs/introduction.md
index 7e752b3..61ccc8c 100644
--- a/docs/introduction.md
+++ b/docs/introduction.md
@@ -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
who were invited can read the page.
-**Nothing to install for guests.** A guest joins by opening a link in a
-web browser. Someone who wants a notebook of real files on their own
-computer installs the small Kokkoja program instead; that is also how a
-host runs Kokkoja.
+**Nothing to install.** A guest joins by opening a link in a web
+browser, and a host can start a session from the web site in the same
+way. Someone who wants a notebook of real files on their own computer,
+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
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 host** runs the small Kokkoja program on their own computer.
- It shows Kokkoja in the host's browser, at the address
+* **The host** in the picture runs the small Kokkoja program on their
+ own computer. It shows Kokkoja in the host's browser, at the address
`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
- 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
participants' messages, which are encrypted before they leave each
diff --git a/docs/quickstart.md b/docs/quickstart.md
index e5ce64d..b013382 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -10,9 +10,10 @@ works](implementation.md).
Kokkoja has three parts. The **client** is plain JavaScript that runs
in a browser. The **local program**, `kokkoja.py`, serves the client to
your own browser and keeps your notebooks as directories of SVG files;
-a host needs it, a guest does not. The **relay** passes the
-participants' messages, encrypted end to end so that it cannot read
-them, and serves the client to guests as a web site; Andy's relay at
+without it the notebooks live in the browser's storage. The **relay**
+passes the participants' messages, encrypted end to end so that it
+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.
## Requirements
@@ -36,6 +37,10 @@ the directory.
## 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
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
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
explanation. Each participant writes on a layer of their own that
nobody else can change. The page is saved continuously to the current
diff --git a/kokkoja.py b/kokkoja.py
index 1a5e9d8..c60da58 100644
--- a/kokkoja.py
+++ b/kokkoja.py
@@ -27,6 +27,7 @@ import os
import pathlib
import re
import secrets
+import shutil
import subprocess
import sys
import threading
@@ -42,6 +43,22 @@ MAX_PAGE_BYTES = 64 << 20
CURRENT_PAGE = "Current.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 , and only there; typed text (in ) may contain
+# anything, so the search stops at the paper.
+VOLATILE = re.compile(rb' data-(?:session|host|page|number)="[^"]*"|[^<]* \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"/ a page
PUT /api/notebooks// write a page
POST /api/notebooks//keep keep a copy of Current.svg
+ DELETE /api/notebooks/ the notebook, pages and all
+ DELETE /api/notebooks// 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():
return
@@ -337,6 +434,16 @@ class Handler(http.server.SimpleHTTPRequestHandler):
self.send_json({"kept": name, "new": new})
elif method == "POST":
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":
if len(names) != 2:
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]})
elif len(names) == 0:
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:
self.send_json({"pages": self.listing(target, directories=False)})
else:
@@ -474,10 +582,13 @@ class Handler(http.server.SimpleHTTPRequestHandler):
(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,), {
"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))
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,
help="directory for Kokkoja's own settings "
"(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",
help="do not open a browser window")
arguments = parser.parse_args()
@@ -511,7 +627,10 @@ def main():
" python3 kokkoja.py --relay wss://kokkoja.example/relay\n"
"Afterwards the address is remembered and python3 kokkoja.py is enough.\n")
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}"
print(f"Kokkoja is at {address}", flush=True)
print(f"Relay: {relay}" + (" (remembered from last time)" if remembered else ""), flush=True)
diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py
index 53e96e1..4addccb 100644
--- a/tests/test_end_to_end.py
+++ b/tests/test_end_to_end.py
@@ -521,6 +521,8 @@ class EndToEndTest(unittest.TestCase):
cls.processes = []
cls.directory = tempfile.TemporaryDirectory()
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()
cls.start([sys.executable, str(PROJECT / "relay" / "kokkoja_relay.py"),
"--port", str(relay_port)])
@@ -552,7 +554,8 @@ class EndToEndTest(unittest.TestCase):
site_option = ["--site", cls.site] if name == "Host" else []
cls.start([sys.executable, str(PROJECT / "kokkoja.py"), "--no-browser",
"--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.start(["geckodriver", "--port", str(driver_port)])
try:
@@ -824,6 +827,21 @@ class EndToEndTest(unittest.TestCase):
lambda v: v.startswith("Already kept"), "the second keep to be reported")
time.sleep(1)
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.
firefox.run("document.getElementById('export_page').click();")
@@ -2072,11 +2090,11 @@ class EndToEndTest(unittest.TestCase):
# is the student's again.
firefox.run("document.getElementById('load_notebook_page').click();")
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")
self.assertEqual(notebooks, sorted(notebooks))
firefox.run("""
- [...document.querySelectorAll('#chooser_notebooks button')]
+ [...document.querySelectorAll('#chooser_notebooks button.notebook_pick')]
.find((b) => b.textContent === 'Teacher notebook').click();
""")
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")
self.assertEqual(loaded["images"], stacked["images"])
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
# shown at the scale at which one sheet fits the window.
self.assertEqual(firefox.run(VIEW), long_view)
diff --git a/tests/test_local_program.py b/tests/test_local_program.py
index adf6f76..7eef3d5 100644
--- a/tests/test_local_program.py
+++ b/tests/test_local_program.py
@@ -28,14 +28,29 @@ TEXT_ONLY_LAYER = (b' \n'
+def page_named(session, page, number, body=b' '):
+ """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'\n'
+ b'\n'
+ b'Kokkoja page ' + number + b' \n'
+ b' \n' + body + b' \n')
+
+
class LocalProgramTest(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.notebooks = pathlib.Path(self.directory.name) / "notebooks"
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.config)
+ self.config, trash=kokkoja.Trash(self.trash))
self.port = self.server.server_address[1]
thread = threading.Thread(target=self.server.serve_forever, daemon=True)
thread.start()
@@ -166,6 +181,83 @@ class LocalProgramTest(unittest.TestCase):
self.request("PUT", current, body=b'' + TEXT_ONLY_LAYER + b" \n")
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'data-page="p1" ')
+ 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'data-page="p1"', b'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):
notebook = self.notebooks / "Andy"
notebook.mkdir(parents=True)