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:
1 parent
4e3a281d65
commit
0d01e23d7c
15 files changed
+711
-63
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user