notes
All repositories: gitoria
15.7 KB
// notes-offline.js — THE NOTES AREA (ticket notes#2): the notes live in the browser (IndexedDB, one database per user)// and are the ones the page shows and edits — online or not. Every change is written to IndexedDB first, then sent// to the server through /api/notes; the server's changes (other devices) are pulled the same way. So the notes// open and edit with no network, and what was changed offline is sent when the network is back.//// local note { id, text, base, at, dirty, fresh, gone, created }// base = the server's `edited` of the version this device last saw (0 = never synced)// at = when the note was last edited (ms; the server's time once synced)// dirty = changed here, not yet sent fresh = made here, no server id yet (id = 'l…', server assigns its own)// gone = deleted here, the delete not yet sent// CONFLICT (a note changed here and on another device): the server keeps the later edit, the other version becomes// a copy — a note of its own — that arrives here in the answer. Nothing is lost.//// The page (components/notes.hl) only carries <note-book id="notesapp">; this file fills it, with the same tags and// ids the old server-rendered version had (styles.hl lays them out). It runs once per document (the shell re-creates// its <script>s when the login state flips); a MutationObserver mounts and unmounts it as #notesapp comes and goes.(() => {if (window.__notesOffline) { window.__notesOffline.check(); return; }const CACHE = 'notes-shell-v1';const OFFLINE = 'Offline — your changes are kept in this browser and sent when you are back online.';const S = { app: null, user: null, db: null, notes: new Map(), sel: null, loaded: false, timer: 0, poll: 0, bc: null, gen: 0, dirtyWait: 0 };// ---- small helpers --------------------------------------------------------------------------------------------const h = (tag, attrs, ...kids) => {const el = document.createElement(tag);for (const k in attrs || {}) { if (k === 'text') el.textContent = attrs[k]; else el.setAttribute(k, attrs[k]); }for (const c of kids) el.append(c);return el;};const $ = (sel) => S.app && S.app.querySelector(sel);const rand = () => 'l' + Array.from(crypto.getRandomValues(new Uint8Array(8)), (b) => b.toString(16).padStart(2, '0')).join('');const subjectOf = (text) => {for (const raw of text.split('\n')) {const line = raw.trim().replace(/^#+/, '').trim();if (line) return line.length > 80 ? line.slice(0, 80) : line;}return 'Untitled note';};// ---- IndexedDB (promises around the one store) ------------------------------------------------------------------const wrap = (req) => new Promise((ok, no) => { req.onsuccess = () => ok(req.result); req.onerror = () => no(req.error); });const openDb = (user) => new Promise((ok, no) => {const r = indexedDB.open('notes-' + user, 1);r.onupgradeneeded = () => r.result.createObjectStore('notes', { keyPath: 'id' });r.onsuccess = () => ok(r.result);r.onerror = () => no(r.error);});const store = (mode) => S.db.transaction('notes', mode).objectStore('notes');const dbAll = () => wrap(store('readonly').getAll());const put = (rec) => { S.notes.set(rec.id, rec); return wrap(store('readwrite').put(rec)); };const drop = (id) => { S.notes.delete(id); return wrap(store('readwrite').delete(id)); };const reload = async () => { S.notes = new Map((await dbAll()).map((r) => [r.id, r])); };const tell = () => { if (S.bc) S.bc.postMessage('changed'); };// ---- the page ---------------------------------------------------------------------------------------------------const alive = () => [...S.notes.values()].filter((r) => !r.gone).sort((a, b) => b.at - a.at);const setState = (text) => { const p = $('#syncstate'); if (p) p.textContent = text; };const setSaveError = (text) => { const p = $('#saveerror'); if (p) p.textContent = text; };function build() {const list = h('note-list', {},h('header', {}, h('h1', { text: 'Notes' }), h('button', { id: 'newnote', type: 'button', text: 'New note' })),h('p', { id: 'syncstate', class: 'muted' }),h('p', { id: 'empty', class: 'empty', text: 'No notes yet.' }),h('ol', { id: 'notes' }));S.app.replaceChildren(list, h('note-reader'));list.querySelector('#newnote').addEventListener('click', createNote);}function paintList() {const rows = alive();const ol = $('#notes');if (!ol) return;$('#empty').hidden = rows.length > 0;ol.replaceChildren(...rows.map((r) => {const a = h('a', { href: '/note/' + r.id, text: subjectOf(r.text) });a.dataset.note = r.id;if (r.id === S.sel) a.className = 'selected';return h('li', {}, a);}));}function paintReader() {const reader = $('note-reader');if (!reader) return;S.app.classList.toggle('open', S.sel != null);const rec = S.sel != null ? S.notes.get(S.sel) : null;if (S.sel == null) return reader.replaceChildren(h('p', { id: 'pick', class: 'empty', text: 'Select a note or create a new one.' }));if (!rec || rec.gone) {if (!S.loaded) return reader.replaceChildren(h('p', { id: 'loading', class: 'empty', text: 'Loading…' }));const a = h('a', { href: '/', text: 'Back to your notes' });a.dataset.note = '';return reader.replaceChildren(h('p', { id: 'missing', class: 'empty' }, 'This note does not exist any more. ', a));}const back = h('a', { class: 'back', href: '/', text: 'All notes' });back.dataset.note = '';const ta = h('textarea', { id: 'notetext', name: 'text', rows: '16' });ta.value = rec.text;ta.addEventListener('input', () => edited(ta.value));const ask = h('p', { id: 'deleteask', class: 'ask' }, 'Delete this note for good? ',h('button', { id: 'deleteyes', type: 'button', class: 'danger', text: 'Delete' }),h('button', { id: 'deleteno', type: 'button', class: 'quiet', text: 'Keep' }));const del = h('button', { id: 'delete', type: 'button', class: 'quiet', text: 'Delete note' });del.addEventListener('click', () => { ask.className = 'ask open'; });ask.querySelector('#deleteno').addEventListener('click', () => { ask.className = 'ask'; });ask.querySelector('#deleteyes').addEventListener('click', () => removeNote(rec.id));reader.replaceChildren(h('note-editor', {}, back, h('md-editor', {}, ta), h('p', { id: 'saveerror', class: 'message' }), h('footer', {}, del, ask)));}// the note open in the editor changed from OUTSIDE (a pull, another tab): show it, unless this tab is typing into itfunction refreshOpen() {const ta = $('#notetext');const rec = S.sel != null ? S.notes.get(S.sel) : null;if (!ta || !rec || rec.gone) { if (ta || (S.sel != null && !rec)) paintReader(); return; }if (ta.value !== rec.text && !rec.dirty) paintReader();}function select(id) {S.sel = id || null;history.replaceState({}, '', S.sel ? '/note/' + S.sel : '/');paintList();paintReader();}// ---- what the person does ---------------------------------------------------------------------------------------async function createNote() {const t = Date.now();const rec = { id: rand(), text: '', base: 0, at: t, dirty: true, fresh: true, gone: false, created: t };await put(rec);tell();select(rec.id);later();}async function edited(value) {const rec = S.notes.get(S.sel);if (!rec || rec.text === value) return;rec.text = value; rec.at = Date.now(); rec.dirty = true;await put(rec);tell();paintList();later();}async function removeNote(id) {const rec = S.notes.get(id);if (!rec) return;if (rec.fresh) await drop(id);else { rec.gone = true; rec.dirty = true; await put(rec); }tell();select(null);later();}// ---- the sync ---------------------------------------------------------------------------------------------------const later = () => { clearTimeout(S.timer); S.timer = setTimeout(() => sync(), 600); };const api = async (method, path, body) => {const res = await fetch(path, { method, credentials: 'same-origin', cache: 'no-store', headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined });if (res.status === 401) throw Object.assign(new Error('login'), { login: true });const json = await res.json().catch(() => null);if (!res.ok) throw Object.assign(new Error((json && json.error) || 'HTTP ' + res.status), { server: true });return json;};const take = (rec, r) => ({ ...rec, id: r.id, text: r.text, base: r.edited, at: r.edited, dirty: false, fresh: false, gone: false });async function pushOne(rec) {if (rec.gone) {const r = await api('POST', '/api/notes', { op: 'delete', id: rec.id, base: rec.base });if (r.kept) await put(take(rec, r)); // changed elsewhere meanwhile: the note stayselse await drop(rec.id);return;}const sent = rec.text;const r = await api('POST', '/api/notes', { op: 'save', id: rec.fresh ? '' : rec.id, local: rec.fresh ? rec.id : '', text: sent, base: rec.base, at: rec.at });const now = S.notes.get(rec.id) || rec;const typedMeanwhile = now.text !== sent;if (r.id !== rec.id) {await drop(rec.id);if (S.sel === rec.id) { S.sel = r.id; history.replaceState({}, '', '/note/' + r.id); }}if (typedMeanwhile) await put({ ...now, id: r.id, base: r.edited, fresh: false, dirty: true });else await put(take(now, r));if (r.copy) await put({ id: r.copy.id, text: r.copy.text, base: r.copy.edited, at: r.copy.edited, dirty: false, fresh: false, gone: false, created: r.copy.edited });}async function pull() {const { notes } = await api('GET', '/api/notes');const seen = new Set();for (const m of notes) {seen.add(m.id);const loc = S.notes.get(m.id);if (m.gone) { if (loc && !loc.dirty) await drop(m.id); continue; }if (loc && (loc.dirty || loc.base === m.edited)) continue;const one = await api('GET', '/api/notes?id=' + encodeURIComponent(m.id)).catch((e) => { if (e.server) return null; throw e; });if (!one) continue;const again = S.notes.get(m.id);if (again && again.dirty) continue; // typed while this was on its wayawait put({ id: one.id, text: one.text, base: one.edited, at: one.edited, dirty: false, fresh: false, gone: false, created: one.created });}for (const loc of [...S.notes.values()]) if (!loc.fresh && !loc.dirty && !seen.has(loc.id)) await drop(loc.id);}async function syncOnce() {if (!S.db || navigator.onLine === false) throw new Error('offline');try {for (const rec of [...S.notes.values()].filter((r) => r.dirty)) await pushOne(rec);await pull();setState('');} catch (e) {if (e.login) setState('Your login has expired — log in again to sync your notes.');else if (e.server) setState('Could not sync: ' + e.message);else setState(OFFLINE);throw e;}}// one sync at a time across all tabs (Web Locks); a failed one just waits for the next tryasync function sync() {const gen = S.gen;const run = async () => {try { await syncOnce(); } catch (e) { /* shown in the state line */ }S.loaded = true;if (gen !== S.gen || !S.app) return;tell();paintList();refreshOpen();if (S.sel != null && !S.notes.get(S.sel)) paintReader();};if (navigator.locks) await navigator.locks.request('notes-sync', run); else await run();}// ---- offline pages: the service worker and the copy of the page it keeps ---------------------------------------async function keepShell() {if (!('serviceWorker' in navigator) || !window.caches) return;try {await navigator.serviceWorker.register('/sw.js');await navigator.serviceWorker.ready;const cache = await caches.open(CACHE);const page = await fetch('/', { credentials: 'same-origin', cache: 'no-store' });if (page.ok && !page.redirected) await cache.put('/shell', page);const urls = performance.getEntriesByType('resource').map((e) => e.name).filter((u) => u.startsWith(location.origin) && !u.includes('/api/'));urls.push(location.origin + '/md-editor.js', location.origin + '/notes-offline.js', location.origin + '/login.js');for (const u of new Set(urls)) { if (!(await cache.match(u))) { try { await cache.add(u); } catch (e) { /* not kept */ } } }} catch (e) { /* no service worker here: online use is unchanged */ }}// ---- mount / unmount ----------------------------------------------------------------------------------------------async function whoAmI() {try {const j = await (await fetch('/api/me', { credentials: 'same-origin', cache: 'no-store' })).json();if (j && j.user) { localStorage.setItem('notes.user', j.user); return j.user; }return null; // online, and not logged in} catch (e) { return localStorage.getItem('notes.user'); } // offline: the last user of this browser}async function mount(el) {const gen = ++S.gen;S.app = el; S.loaded = false;const user = await whoAmI();if (gen !== S.gen || S.app !== el) return;if (!user) { el.replaceChildren(h('p', { class: 'muted', text: 'Log in with ident (top right) to see your notes.' })); return; }S.user = user;S.db = await openDb(user);await reload();const m = location.pathname.match(/^\/note\/([^/]+)$/);S.sel = m ? decodeURIComponent(m[1]) : null;build(); paintList(); paintReader();if (window.BroadcastChannel) {S.bc = new BroadcastChannel('notes-' + user);S.bc.onmessage = async () => { await reload(); paintList(); refreshOpen(); };}el.addEventListener('click', (e) => {const a = e.target.closest && e.target.closest('a[data-note]');if (!a || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;e.preventDefault(); // claimed: the framework does not navigateselect(a.dataset.note);});S.poll = setInterval(() => { if (document.visibilityState === 'visible') sync(); }, 10000);sync();keepShell();}function unmount() {S.gen++;clearInterval(S.poll); clearTimeout(S.timer);if (S.bc) S.bc.close();if (S.db) S.db.close();Object.assign(S, { app: null, db: null, bc: null, user: null, notes: new Map(), sel: null });}function check() {const el = document.getElementById('notesapp');if (el && el !== S.app) { if (S.app) unmount(); mount(el); }else if (!el && S.app) unmount();}window.addEventListener('online', () => sync());document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && S.app) sync(); });// LOG OUT: what is not yet on the server goes first, then this browser forgets the notes (a shared computer)document.addEventListener('click', async (e) => {const b = e.target.closest && e.target.closest('#logout');if (!b || b.__passed || !S.db) return;e.stopImmediatePropagation(); e.preventDefault();let clean = false;try { await syncOnce(); clean = ![...S.notes.values()].some((r) => r.dirty); } catch (err) { /* offline: keep the notes */ }if (clean) {const name = 'notes-' + S.user;const user = S.user;unmount();await new Promise((ok) => { const r = indexedDB.deleteDatabase(name); r.onsuccess = r.onerror = r.onblocked = () => ok(); });localStorage.removeItem('notes.user');if (window.caches) await caches.delete(CACHE);}b.__passed = true; b.click();}, true);window.__notesOffline = { check, sync: () => sync(), state: S };new MutationObserver(check).observe(document.documentElement, { subtree: true, childList: true });check();})();
Branches
- mainmain branch
Latest commits
- 47dad68bnotes: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); gates 50/0 + 18/0mre
- a4a2b2aeantcolony#40: tracker missions moved too — references to them in missions/reports/LOG.md updatedmre
- 3dea0ef2notes: Hybriel master 190aa11d (fc838894 GC correctness, #126 closure scopes, #127); gate 50/0mre
- e27c7d71notes: Hybriel master 8efba065 (#126 GC by bytes, #48 lambda params copy; audit: nothing to fix; gate 50/0)mre
- 124613b6antcolony#40: mission references point to the moved missionsmre
- 1ca2f34dantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
- 2bebebdanotes: Hybriel master ff51cf46 (re-vendor round)mre
- 3eff126dnotes#3: installable app (manifest + own icon/favicon; notes' own sw.js kept)mre
- 9883c540deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
- eee693b8deploy.sh: never send .git or .gitignore to Byrodinmre
- c8904061State of 2026-09-27, before the move to gitoriamre