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