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

+3 -1
View File
@@ -93,7 +93,7 @@
<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>
<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 class="group" aria-label="Notebook">
<select id="session_notebook" class="notebook_pick" aria-label="Current notebook"
@@ -152,6 +152,8 @@
<dialog id="notebook_chooser" class="chooser">
<header>
<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>
</header>
<div class="chooser_body">
+37 -4
View File
@@ -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 <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
@@ -112,7 +132,7 @@ export const backend = {
const keys = /** @type {[string, string][]} */ (await result(pages.getAllKeys()));
return [...new Set(keys.map(([notebook]) => 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=', '<image'].some((mark) => 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])); });
},
};
+194 -11
View File
@@ -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<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());
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<string, HTMLImageElement>} */
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<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() {
for (const address of this.pictureAddresses) URL.revokeObjectURL(address);
this.pictureAddresses = [];
+5 -2
View File
@@ -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 },
} });
}
+34 -3
View File
@@ -34,12 +34,15 @@ const RETRY_MS = 3000;
* @typedef {object} Backend
* @property {() => Promise<string | null>} readIdentity
* @property {(text: string) => Promise<boolean>} keepIdentity
* @property {() => Promise<{directory: string, notebooks: string[]}>} listNotebooks
* The directory is '' when the notebooks are in the browser.
* @property {() => 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<string[]>} listPages Every page file, Current.svg too.
* @property {(notebook: string, pageName: string) => Promise<string | null>} readPage
* @property {(notebook: string, pageName: string, text: string) => Promise<void>} writePage
* @property {(notebook: string) => Promise<{kept: string | null, new: boolean}>} keepPage
* @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}).`);
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<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
* @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 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;