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$/;
|
2026-10-11 16:59:03 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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();
|
|
|
|
|
|
});
|
2026-10-11 16:59:03 +02:00
|
|
|
|
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
|
2026-10-11 16:59:03 +02:00
|
|
|
|
// 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])));
|
2026-10-10 19:01:20 +02:00
|
|
|
|
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));
|
2026-10-11 16:59:03 +02:00
|
|
|
|
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 };
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
2026-10-11 16:59:03 +02:00
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
};
|