Files
kokkoja-rel/client/js/browser_notebook.js
T

187 lines
6.9 KiB
JavaScript
Raw Normal View History

2026-10-06 15:58:02 +02:00
// @ts-check
/**
* Notebooks kept in the browser, for Kokkoja opened from a web site with
* no local program on the computer.
*
* The notebooks follow the same rules as the directories the local program
* keeps (`notebook.js`): a notebook holds `Current.svg`, the page being
* written, and dated kept pages such as `2026_10_02_01.svg`. They live in
* the browser's IndexedDB for this site, which survives closing the
* browser but belongs to this browser on this computer alone; the way to
* take a page out is to download it. The identity is kept in the
* browser's localStorage in the same way.
*/
const DATABASE = 'kokkoja';
const PAGES = 'pages';
const IDENTITY_KEY = 'kokkoja_identity';
const CURRENT_PAGE = 'Current.svg';
const KEPT_PAGE = /^(\d{4}_\d{2}_\d{2})_(\d{2,})\.svg$/;
/**
* What names a page file rather than describes the page: the session,
* the host, the page's id and number on the root element, and the title.
* A page loaded from a notebook is a new page with new ones of these, so
* two files of the same page differ here and nowhere else. They stand
* before the paper's <rect>, and only there; typed text may contain
* anything. The same rule as page_content() in kokkoja.py.
*/
const VOLATILE = / data-(?:session|host|page|number)="[^"]*"|<title>[^<]*<\/title>\n?/g;
/**
* @param {string} text A page file.
* @returns {string} The file without what names it, for telling the
* same page in two files.
*/
function pageContent(text) {
const paper = text.indexOf('<rect ');
return paper < 0 ? text.replace(VOLATILE, '')
: text.slice(0, paper).replace(VOLATILE, '') + text.slice(paper);
}
2026-10-06 15:58:02 +02:00
/**
* @typedef {object} StoredPage
* @property {string} notebook
* @property {string} name
* @property {string} text
* @property {number} changed When the page was last written, in milliseconds.
*/
/** @type {Promise<IDBDatabase> | null} */
let opening = null;
/** @returns {Promise<IDBDatabase>} */
function database() {
opening ??= new Promise((resolve, reject) => {
const request = indexedDB.open(DATABASE, 1);
request.onupgradeneeded = () => {
const store = request.result.createObjectStore(PAGES, { keyPath: ['notebook', 'name'] });
store.createIndex('notebook', 'notebook');
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error('The browser’s storage cannot be opened.'));
});
return opening;
}
/**
* @template T
* @param {IDBRequest<T>} request
* @returns {Promise<T>}
*/
function result(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error('The browser’s storage failed.'));
});
}
/**
* Run work in one transaction on the pages.
* @template T
* @param {IDBTransactionMode} mode
* @param {(pages: IDBObjectStore) => Promise<T>} work
* @returns {Promise<T>}
*/
async function transaction(mode, work) {
const db = await database();
const tx = db.transaction(PAGES, mode);
const done = new Promise((resolve, reject) => {
tx.oncomplete = () => resolve(undefined);
tx.onerror = () => reject(tx.error ?? new Error('The browser’s storage failed.'));
tx.onabort = () => reject(tx.error ?? new Error('The browser’s storage failed.'));
});
const value = await work(tx.objectStore(PAGES));
await done;
return value;
}
/**
* @param {IDBObjectStore} pages
* @param {string} notebook
* @returns {Promise<StoredPage[]>} The notebook's pages, by name.
*/
async function pagesOf(pages, notebook) {
const found = /** @type {StoredPage[]} */ (await result(pages.index('notebook').getAll(notebook)));
return found.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
}
/**
* @param {number} time Milliseconds.
* @returns {string} The day, as 2026_10_02.
*/
function dayOf(time) {
const date = new Date(time);
return [date.getFullYear(), date.getMonth() + 1, date.getDate()]
.map((part) => String(part).padStart(2, '0')).join('_');
}
/** @type {import('./notebook.js').Backend} */
export const backend = {
async readIdentity() {
return localStorage.getItem(IDENTITY_KEY);
},
async keepIdentity(text) {
if (localStorage.getItem(IDENTITY_KEY) !== null) return false;
localStorage.setItem(IDENTITY_KEY, text);
return true;
},
async listNotebooks() {
const names = await transaction('readonly', async (pages) => {
const keys = /** @type {[string, string][]} */ (await result(pages.getAllKeys()));
return [...new Set(keys.map(([notebook]) => notebook))].sort();
});
return { directory: '', notebooks: names, trash: false };
2026-10-06 15:58:02 +02:00
},
async listPages(notebook) {
2026-10-11 18:05:49 +02:00
return transaction('readonly', async (pages) =>
(await pagesOf(pages, notebook)).map((page) => ({ name: page.name, changed: page.changed })));
2026-10-06 15:58:02 +02:00
},
async readPage(notebook, pageName) {
const page = /** @type {StoredPage | undefined} */ (await transaction(
'readonly', (pages) => result(pages.get([notebook, pageName]))));
return page ? page.text : null;
},
async writePage(notebook, pageName, text) {
await transaction('readwrite', async (pages) => {
await result(pages.put({ notebook, name: pageName, text, changed: Date.now() }));
});
},
// The same rules as keep_page() in kokkoja.py: nothing is kept of a
// blank page or of one already kept (the same page, whatever names the
// file); the name is the day the page was last changed and a serial
// number.
2026-10-06 15:58:02 +02:00
async keepPage(notebook) {
return transaction('readwrite', async (pages) => {
const current = /** @type {StoredPage | undefined} */ (await result(pages.get([notebook, CURRENT_PAGE])));
const blank = !['data-stroke=', 'data-text=', '<image'].some((mark) => current?.text.includes(mark));
if (!current || blank) return { kept: null, new: false };
2026-10-06 15:58:02 +02:00
const kept = (await pagesOf(pages, notebook)).filter((page) => KEPT_PAGE.test(page.name));
const content = pageContent(current.text);
const same = kept.find((page) => pageContent(page.text) === content);
2026-10-06 15:58:02 +02:00
if (same) return { kept: same.name, new: false };
const day = dayOf(current.changed);
const serials = kept.map((page) => /** @type {RegExpExecArray} */ (KEPT_PAGE.exec(page.name)))
.filter((match) => match[1] === day).map((match) => Number(match[2]));
const name = `${day}_${String(Math.max(0, ...serials) + 1).padStart(2, '0')}.svg`;
await result(pages.put({ notebook, name, text: current.text, changed: current.changed }));
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])); });
},
2026-10-06 15:58:02 +02:00
};