diff --git a/client/js/browser_notebook.js b/client/js/browser_notebook.js index 8809749..8730c9b 100644 --- a/client/js/browser_notebook.js +++ b/client/js/browser_notebook.js @@ -136,7 +136,8 @@ export const backend = { }, async listPages(notebook) { - return transaction('readonly', async (pages) => (await pagesOf(pages, notebook)).map((page) => page.name)); + return transaction('readonly', async (pages) => + (await pagesOf(pages, notebook)).map((page) => ({ name: page.name, changed: page.changed }))); }, async readPage(notebook, pageName) { diff --git a/client/js/chooser.js b/client/js/chooser.js index 359f4d0..3bc1448 100644 --- a/client/js/chooser.js +++ b/client/js/chooser.js @@ -21,7 +21,13 @@ * so to anyone else. */ -import { discardNotebook, discardPage, listKeptPages, listNotebooks, readPage } from './notebook.js'; +import { + currentPageChanged, discardNotebook, discardPage, listKeptPageDetails, listNotebooks, readPage, +} from './notebook.js'; +import { pageFromSvg } from './page_file.js'; + +/** How much of a page's first text the card's tooltip shows. */ +const FIRST_TEXT_CHARS = 60; /** * @param {string} id @@ -49,6 +55,83 @@ function counted(count, noun) { return `${count} ${noun}${count === 1 ? '' : 's'}`; } +/** + * @param {number} time Milliseconds since the epoch. + * @returns {string} The day and the time of day, in the user's notation. + */ +function when(time) { + const date = new Date(time); + return `${date.toLocaleDateString()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; +} + +/** + * The facts about a kept page for its card's tooltip (Andy, 2026-10-11): + * the first text on it, if any, as a heading (the block nearest the + * top, then nearest the left: reading order), then when it was last + * changed, who wrote on it, what is on it, and the sheet's size. + * @param {string} text The page file. + * @param {number} changed When it was last written, in milliseconds. + * @returns {string} + */ +export function describePage(text, changed) { + const read = pageFromSvg(text); + if (!read) return 'Not a Kokkoja page.'; + const { page } = read; + const lines = []; + /** @type {{x: number, y: number, text: string} | null} */ + let first = null; + let strokes = 0; + let blocks = 0; + const writers = []; + for (const layer of page.layers.values()) { + if (layer.isEmpty()) continue; + writers.push(layer.name || 'Unnamed'); + strokes += layer.strokes.size; + blocks += layer.texts.size; + for (const block of layer.texts.values()) { + if (!block.text.trim()) continue; + if (!first || block.y < first.y || (block.y === first.y && block.x < first.x)) first = block; + } + } + if (first) { + const heading = first.text.replace(/\s+/g, ' ').trim(); + lines.push(heading.length > FIRST_TEXT_CHARS ? `${heading.slice(0, FIRST_TEXT_CHARS - 1)}…` : heading); + } + if (changed) lines.push(`Last changed ${when(changed)}`); + if (writers.length) lines.push(`Written by ${writers.join(', ')}`); + const contents = []; + if (page.images.length) contents.push(counted(page.images.length, 'image')); + if (strokes) contents.push(counted(strokes, 'stroke')); + if (blocks) contents.push(counted(blocks, 'text block')); + lines.push(contents.length ? contents.join(', ') : 'Nothing on it'); + lines.push(`Sheet ${(page.baseWidth / 100).toFixed(1)} × ${(page.baseHeight / 100).toFixed(1)} cm`); + return lines.join('\n'); +} + +/** + * The facts about a notebook for its button's tooltip: how many pages + * are kept and from when to when, when it was last written in, and + * where it is. + * @param {string} notebook + * @param {string} directory Where the notebooks are, or '' in the browser. + * @returns {Promise} + */ +async function describeNotebook(notebook, directory) { + const kept = await listKeptPageDetails(notebook); + const written = await currentPageChanged(notebook); + const lines = []; + if (kept.length) { + const days = kept.map((page) => pageTitle(page.name).slice(0, 10)); + const span = days[0] === days[days.length - 1] ? `on ${days[0]}` : `from ${days[0]} to ${days[days.length - 1]}`; + lines.push(`${counted(kept.length, 'kept page')}, ${span}`); + } else { + lines.push('No kept pages'); + } + if (written) lines.push(`Last written in ${when(written)}`); + lines.push(directory ? `In ${directory}/${notebook}` : 'Kept in this browser'); + return lines.join('\n'); +} + /** * A mark button: × until marked, then ✓. * @param {string} help The tooltip. @@ -116,9 +199,9 @@ export class NotebookChooser { element('chooser_message').textContent = ''; element('chooser_pages').replaceChildren(); // Nothing stale while the lists load. try { - const { notebooks, trash } = await listNotebooks(); + const { notebooks, trash, directory } = await listNotebooks(); this.trash = trash; - this.showNotebooks(notebooks); + this.showNotebooks(notebooks, directory); // The notebook being written in is chosen already, its pages shown; // the list is always there to see (Andy, 2026-10-06). const chosen = notebook && notebooks.includes(notebook) ? notebook @@ -134,8 +217,11 @@ export class NotebookChooser { element('chooser_message').textContent = text; } - /** @param {string[]} notebooks */ - showNotebooks(notebooks) { + /** + * @param {string[]} notebooks + * @param {string} [directory] Where the notebooks are, for their tooltips. + */ + showNotebooks(notebooks, directory = '') { const list = element('chooser_notebooks'); list.replaceChildren(); for (const notebook of notebooks) { @@ -145,6 +231,10 @@ export class NotebookChooser { button.type = 'button'; button.className = 'notebook_pick'; button.textContent = notebook; + button.dataset.help = `The notebook “${notebook}”.`; + describeNotebook(notebook, directory).then((facts) => { + if (button.isConnected) button.dataset.help = facts; // Not a list since replaced. + }).catch(() => {}); button.addEventListener('click', () => { this.showPages(notebook); }); const mark = markButton(notebook === this.writing ? 'This is the notebook you are writing in. To delete it, choose another notebook in the toolbar first.' @@ -178,14 +268,14 @@ export class NotebookChooser { return; } const opening = this.opening += 1; - const pages = await listKeptPages(notebook); + const pages = await listKeptPageDetails(notebook); if (opening !== this.opening) return; this.say(pages.length ? '' : 'This notebook has no kept pages.'); const marked = this.markedPages.get(notebook) ?? new Set(); - /** @type {Map} */ + /** @type {Map} */ const pictures = new Map(); - for (const pageName of pages) { + for (const { name: pageName, changed } of pages) { const slot = document.createElement('div'); slot.className = 'page_slot'; const card = document.createElement('button'); @@ -193,7 +283,8 @@ export class NotebookChooser { card.className = 'page_card'; const picture = document.createElement('img'); picture.alt = ''; - pictures.set(pageName, picture); + pictures.set(pageName, { picture, card, changed }); + card.dataset.help = `Kept as ${pageName}.`; const title = document.createElement('span'); title.textContent = pageTitle(pageName); card.append(picture, title); @@ -219,14 +310,16 @@ export class NotebookChooser { } this.markedPages.set(notebook, marked); - // The pictures are the page files themselves, fetched one by one. - for (const [pageName, picture] of pictures) { + // The pictures are the page files themselves, fetched one by one; the + // same text gives the card's tooltip. + for (const [pageName, { picture, card, changed }] of pictures) { const text = await readPage(notebook, pageName); if (opening !== this.opening) return; if (text === null) continue; const address = URL.createObjectURL(new Blob([text], { type: 'image/svg+xml' })); this.pictureAddresses.push(address); picture.src = address; + card.dataset.help = describePage(text, changed); } } diff --git a/client/js/notebook.js b/client/js/notebook.js index 19762d3..ea3b9ea 100644 --- a/client/js/notebook.js +++ b/client/js/notebook.js @@ -29,6 +29,13 @@ const LONGEST_WAIT_MS = 2000; /** After a write fails, try again this much later. */ const RETRY_MS = 3000; +/** + * A page file in a notebook. + * @typedef {object} KeptPage + * @property {string} name + * @property {number} changed When it was last written, in milliseconds since the epoch. + */ + /** * What keeps the notebooks: the local program's files, or the browser. * @typedef {object} Backend @@ -37,7 +44,7 @@ const RETRY_MS = 3000; * @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) => 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 @@ -101,7 +108,8 @@ const inFiles = /** @type {Backend} */ ({ async listPages(notebook) { const response = await request('GET', [notebook]); if (!response.ok) throw new Error(`The notebook “${notebook}” cannot be read.`); - return (await response.json()).pages; + const { pages, changed } = await response.json(); + return pages.map((/** @type {string} */ name) => ({ name, changed: changed?.[name] ?? 0 })); }, async readPage(notebook, pageName) { const response = await request('GET', [notebook, pageName]); @@ -189,8 +197,27 @@ export function discardPage(notebook, pageName) { * notebook, in order; not the page being written. */ export async function listKeptPages(notebook) { + return (await listKeptPageDetails(notebook)).map((page) => page.name); +} + +/** + * @param {string} notebook + * @returns {Promise} The pages kept in the notebook, in + * order, with when each was last changed; not the page being written. + */ +export async function listKeptPageDetails(notebook) { const pages = await backend.listPages(notebook); - return pages.filter((name) => name !== CURRENT_PAGE); + return pages.filter((page) => page.name !== CURRENT_PAGE); +} + +/** + * @param {string} notebook + * @returns {Promise} When the page being written in the notebook + * was last written, in milliseconds since the epoch, or 0 if there is none. + */ +export async function currentPageChanged(notebook) { + const pages = await backend.listPages(notebook); + return pages.find((page) => page.name === CURRENT_PAGE)?.changed ?? 0; } /** diff --git a/client/js/tooltip.js b/client/js/tooltip.js index f4272de..54cb1b9 100644 --- a/client/js/tooltip.js +++ b/client/js/tooltip.js @@ -50,6 +50,10 @@ function show(element) { tooltip.setAttribute('role', 'tooltip'); document.body.append(tooltip); } + // A modal dialog is above everything in the body, its tooltips included, + // so a tooltip for an element in one lives in the dialog. + const home = element.closest('dialog[open]') ?? document.body; + if (tooltip.parentElement !== home) home.append(tooltip); tooltip.textContent = element.getAttribute('data-help'); tooltip.hidden = false; shownFor = element; diff --git a/client/kokkoja.css b/client/kokkoja.css index 432d149..16015c9 100644 --- a/client/kokkoja.css +++ b/client/kokkoja.css @@ -571,6 +571,7 @@ button.mark.unavailable { opacity: 0.4; } color: white; font-size: 0.85rem; line-height: 1.35; + white-space: pre-line; /* A page card's tooltip has several lines. */ box-shadow: 0 2px 8px rgb(0 0 0 / 0.25); pointer-events: none; } diff --git a/docs/guest.md b/docs/guest.md index 4a180ae..65a85fc 100644 --- a/docs/guest.md +++ b/docs/guest.md @@ -218,6 +218,10 @@ doing anything. Where it is saved depends on how you joined. **Notebooks** in the toolbar opens a chooser that shows your notebooks and small pictures of the pages kept in them, wherever they are kept. +Rest the pointer on a page for a second and its facts appear: the first +text on it, when it was last changed, who wrote on it, what is on it +and the size of the sheet; on a notebook, how many pages it keeps and +from when, when it was last written in, and where it is. There you can delete pages and notebooks you no longer want: mark them with the × at their left, then press **Delete** twice. In the browser a deleted page is gone for good; with the Kokkoja program it goes to the diff --git a/docs/implementation.md b/docs/implementation.md index 945c435..0534366 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -177,7 +177,10 @@ a page of another session in the notebook. Nothing is copied if the 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 +whole notebook can be deleted from the Notebooks chooser, whose +tooltips describe each page (from the page file itself, which the +chooser fetches for the picture) and each notebook (from the listing, +which names the pages and says when each was last written); 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 diff --git a/kokkoja.py b/kokkoja.py index c60da58..f7f675a 100644 --- a/kokkoja.py +++ b/kokkoja.py @@ -403,7 +403,8 @@ class Handler(http.server.SimpleHTTPRequestHandler): def notebook_request(self, method, path): """ GET /api/notebooks the notebooks - GET /api/notebooks/ the pages of a notebook + GET /api/notebooks/ the pages of a notebook, and + when each was last changed GET /api/notebooks// a page PUT /api/notebooks// write a page POST /api/notebooks//keep keep a copy of Current.svg @@ -460,7 +461,9 @@ class Handler(http.server.SimpleHTTPRequestHandler): "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)}) + pages = self.listing(target, directories=False) + self.send_json({"pages": pages, + "changed": {name: int((target / name).stat().st_mtime * 1000) for name in pages}}) else: self.send_body(target.read_bytes(), "image/svg+xml") except FileNotFoundError: diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index 37d4cbd..fa185bc 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -916,6 +916,64 @@ class EndToEndTest(unittest.TestCase): time.sleep(0.8) self.assertEqual(showing(), "Write on the page. O") + # In the Notebooks chooser, a page card's tooltip gives the facts + # about the page, headed by its first text (nearest the top, then + # the left), and a notebook's gives its facts; both shown inside + # the dialog, which is above everything else. + notebook = self.notebooks / "Reader notebook" + notebook.mkdir(parents=True, exist_ok=True) + (notebook / "2026_10_01_01.svg").write_text( + '' + '' + 'Lower text' + 'Lower text' + 'Übung 12: der Dativ' + 'Übung 12: der Dativ') + (notebook / "2026_10_03_01.svg").write_text( + '') + firefox.run("document.getElementById('load_notebook_page').click();") + self.wait_until_true(lambda: firefox.run(""" + return [...document.querySelectorAll('#chooser_notebooks button.notebook_pick')].map((b) => b.textContent); + """), lambda names: "Reader notebook" in names, "the chooser to list the notebooks") + firefox.run(""" + [...document.querySelectorAll('#chooser_notebooks button.notebook_pick')] + .find((b) => b.textContent === 'Reader notebook').click(); + """) + self.wait_until_true(lambda: firefox.run(""" + return [...document.querySelectorAll('#chooser_pages .page_card')].map((c) => c.dataset.help); + """), lambda helps: len(helps) == 2 and all("Sheet" in h for h in helps), "the cards' tooltips to be made") + helps = firefox.run("return [...document.querySelectorAll('#chooser_pages .page_card')].map((c) => c.dataset.help);") + self.assertEqual(helps[0].split("\n")[0], "Übung 12: der Dativ") + self.assertIn("Written by Anna", helps[0]) + self.assertIn("1 stroke, 2 text blocks", helps[0]) + self.assertIn("Sheet 29.7 × 21.0 cm", helps[0]) + self.assertRegex(helps[0], r"Last changed \S+ at \d") + self.assertEqual(helps[1].split("\n")[0], "Last changed " + helps[1].split("\n")[0][13:]) + self.assertIn("Nothing on it", helps[1]) + self.wait_until_true(lambda: firefox.run(""" + return [...document.querySelectorAll('#chooser_notebooks button.notebook_pick')] + .find((b) => b.textContent === 'Reader notebook').dataset.help; + """), lambda help: "kept pages" in help, "the notebook's tooltip to be made") + self.assertIn("2 kept pages, from 2026-10-01 to 2026-10-03", firefox.run(""" + return [...document.querySelectorAll('#chooser_notebooks button.notebook_pick')] + .find((b) => b.textContent === 'Reader notebook').dataset.help; + """)) + firefox.run(""" + document.querySelector('#chooser_pages .page_card') + .dispatchEvent(new PointerEvent('pointerover', {bubbles: true, pointerType: 'mouse'})); + """) + time.sleep(1.3) + self.assertEqual(firefox.run(""" + const t = document.querySelector('dialog[open] .tooltip'); + return t && !t.hidden ? t.textContent.split('\\n')[0] : null; + """), "Übung 12: der Dativ") + firefox.run("document.getElementById('chooser_close').click();") + def test_hide_rectangles(self): """The teacher's requests of 2026-10-08: a Hide rectangle covers part of the page in white for everyone, marked by blue corners.""" @@ -1656,11 +1714,12 @@ class EndToEndTest(unittest.TestCase): const box = tip.getBoundingClientRect(); return {text: tip.textContent, below: box.top > button.bottom, left: box.left, buttonLeft: button.left}; """) - # Two seconds of rest before the tooltip shows (one until 2026-10-06). + # One second in the button before the tooltip shows (two until + # 2026-10-11, one until 2026-10-06). firefox.hover("#new_page") - time.sleep(1.2) + time.sleep(0.5) self.assertIsNone(tooltip()) - time.sleep(1.3) + time.sleep(0.8) shown = tooltip() self.assertEqual(shown["text"], firefox.run( "return document.getElementById('new_page').getAttribute('data-help');")) diff --git a/tests/test_local_program.py b/tests/test_local_program.py index 7eef3d5..405682c 100644 --- a/tests/test_local_program.py +++ b/tests/test_local_program.py @@ -107,8 +107,9 @@ class LocalProgramTest(unittest.TestCase): self.assertEqual(self.request("GET", page), (200, PAGE)) self.assertEqual((self.notebooks / "Übungen A1" / "2026_10_02_page.svg").read_bytes(), PAGE) self.assertEqual(self.json("GET", "/api/notebooks")[1]["notebooks"], ["Übungen A1"]) - self.assertEqual(self.json("GET", "/api/notebooks/" + urllib.parse.quote("Übungen A1")), - (200, {"pages": ["2026_10_02_page.svg"]})) + listed = self.json("GET", "/api/notebooks/" + urllib.parse.quote("Übungen A1")) + self.assertEqual(listed[1]["pages"], ["2026_10_02_page.svg"]) + self.assertAlmostEqual(listed[1]["changed"]["2026_10_02_page.svg"] / 1000, time.time(), delta=10) def test_writing_again_replaces_the_page_and_leaves_nothing_else(self): page = "/api/notebooks/Andy/page.svg" @@ -283,7 +284,7 @@ class LocalProgramTest(unittest.TestCase): def test_missing_page(self): self.assertEqual(self.request("GET", "/api/notebooks/Andy/none.svg")[0], 404) - self.assertEqual(self.json("GET", "/api/notebooks/Andy"), (200, {"pages": []})) + self.assertEqual(self.json("GET", "/api/notebooks/Andy"), (200, {"pages": [], "changed": {}})) # The file chooser and exporting.