gitoriaLog in with ident

notes

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commite27c7d71e27c7d71notes: Hybriel master 8efba065 (#126 GC by bytes, #48 lambda params copy; audit: nothing to fix; gate 50/0)mree27c7d71/notes-offline.js

15.7 KB

  1. // notes-offline.js — THE NOTES AREA (ticket notes#2): the notes live in the browser (IndexedDB, one database per user)
  2. // and are the ones the page shows and edits — online or not. Every change is written to IndexedDB first, then sent
  3. // to the server through /api/notes; the server's changes (other devices) are pulled the same way. So the notes
  4. // open and edit with no network, and what was changed offline is sent when the network is back.
  5. //
  6. // local note { id, text, base, at, dirty, fresh, gone, created }
  7. // base = the server's `edited` of the version this device last saw (0 = never synced)
  8. // at = when the note was last edited (ms; the server's time once synced)
  9. // dirty = changed here, not yet sent fresh = made here, no server id yet (id = 'l…', server assigns its own)
  10. // gone = deleted here, the delete not yet sent
  11. // CONFLICT (a note changed here and on another device): the server keeps the later edit, the other version becomes
  12. // a copy — a note of its own — that arrives here in the answer. Nothing is lost.
  13. //
  14. // The page (components/notes.hl) only carries <note-book id="notesapp">; this file fills it, with the same tags and
  15. // ids the old server-rendered version had (styles.hl lays them out). It runs once per document (the shell re-creates
  16. // its <script>s when the login state flips); a MutationObserver mounts and unmounts it as #notesapp comes and goes.
  17. (() => {
  18. if (window.__notesOffline) { window.__notesOffline.check(); return; }
  19. const CACHE = 'notes-shell-v1';
  20. const OFFLINE = 'Offline — your changes are kept in this browser and sent when you are back online.';
  21. 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 };
  22. // ---- small helpers --------------------------------------------------------------------------------------------
  23. const h = (tag, attrs, ...kids) => {
  24. const el = document.createElement(tag);
  25. for (const k in attrs || {}) { if (k === 'text') el.textContent = attrs[k]; else el.setAttribute(k, attrs[k]); }
  26. for (const c of kids) el.append(c);
  27. return el;
  28. };
  29. const $ = (sel) => S.app && S.app.querySelector(sel);
  30. const rand = () => 'l' + Array.from(crypto.getRandomValues(new Uint8Array(8)), (b) => b.toString(16).padStart(2, '0')).join('');
  31. const subjectOf = (text) => {
  32. for (const raw of text.split('\n')) {
  33. const line = raw.trim().replace(/^#+/, '').trim();
  34. if (line) return line.length > 80 ? line.slice(0, 80) : line;
  35. }
  36. return 'Untitled note';
  37. };
  38. // ---- IndexedDB (promises around the one store) ------------------------------------------------------------------
  39. const wrap = (req) => new Promise((ok, no) => { req.onsuccess = () => ok(req.result); req.onerror = () => no(req.error); });
  40. const openDb = (user) => new Promise((ok, no) => {
  41. const r = indexedDB.open('notes-' + user, 1);
  42. r.onupgradeneeded = () => r.result.createObjectStore('notes', { keyPath: 'id' });
  43. r.onsuccess = () => ok(r.result);
  44. r.onerror = () => no(r.error);
  45. });
  46. const store = (mode) => S.db.transaction('notes', mode).objectStore('notes');
  47. const dbAll = () => wrap(store('readonly').getAll());
  48. const put = (rec) => { S.notes.set(rec.id, rec); return wrap(store('readwrite').put(rec)); };
  49. const drop = (id) => { S.notes.delete(id); return wrap(store('readwrite').delete(id)); };
  50. const reload = async () => { S.notes = new Map((await dbAll()).map((r) => [r.id, r])); };
  51. const tell = () => { if (S.bc) S.bc.postMessage('changed'); };
  52. // ---- the page ---------------------------------------------------------------------------------------------------
  53. const alive = () => [...S.notes.values()].filter((r) => !r.gone).sort((a, b) => b.at - a.at);
  54. const setState = (text) => { const p = $('#syncstate'); if (p) p.textContent = text; };
  55. const setSaveError = (text) => { const p = $('#saveerror'); if (p) p.textContent = text; };
  56. function build() {
  57. const list = h('note-list', {},
  58. h('header', {}, h('h1', { text: 'Notes' }), h('button', { id: 'newnote', type: 'button', text: 'New note' })),
  59. h('p', { id: 'syncstate', class: 'muted' }),
  60. h('p', { id: 'empty', class: 'empty', text: 'No notes yet.' }),
  61. h('ol', { id: 'notes' }));
  62. S.app.replaceChildren(list, h('note-reader'));
  63. list.querySelector('#newnote').addEventListener('click', createNote);
  64. }
  65. function paintList() {
  66. const rows = alive();
  67. const ol = $('#notes');
  68. if (!ol) return;
  69. $('#empty').hidden = rows.length > 0;
  70. ol.replaceChildren(...rows.map((r) => {
  71. const a = h('a', { href: '/note/' + r.id, text: subjectOf(r.text) });
  72. a.dataset.note = r.id;
  73. if (r.id === S.sel) a.className = 'selected';
  74. return h('li', {}, a);
  75. }));
  76. }
  77. function paintReader() {
  78. const reader = $('note-reader');
  79. if (!reader) return;
  80. S.app.classList.toggle('open', S.sel != null);
  81. const rec = S.sel != null ? S.notes.get(S.sel) : null;
  82. if (S.sel == null) return reader.replaceChildren(h('p', { id: 'pick', class: 'empty', text: 'Select a note or create a new one.' }));
  83. if (!rec || rec.gone) {
  84. if (!S.loaded) return reader.replaceChildren(h('p', { id: 'loading', class: 'empty', text: 'Loading…' }));
  85. const a = h('a', { href: '/', text: 'Back to your notes' });
  86. a.dataset.note = '';
  87. return reader.replaceChildren(h('p', { id: 'missing', class: 'empty' }, 'This note does not exist any more. ', a));
  88. }
  89. const back = h('a', { class: 'back', href: '/', text: 'All notes' });
  90. back.dataset.note = '';
  91. const ta = h('textarea', { id: 'notetext', name: 'text', rows: '16' });
  92. ta.value = rec.text;
  93. ta.addEventListener('input', () => edited(ta.value));
  94. const ask = h('p', { id: 'deleteask', class: 'ask' }, 'Delete this note for good? ',
  95. h('button', { id: 'deleteyes', type: 'button', class: 'danger', text: 'Delete' }),
  96. h('button', { id: 'deleteno', type: 'button', class: 'quiet', text: 'Keep' }));
  97. const del = h('button', { id: 'delete', type: 'button', class: 'quiet', text: 'Delete note' });
  98. del.addEventListener('click', () => { ask.className = 'ask open'; });
  99. ask.querySelector('#deleteno').addEventListener('click', () => { ask.className = 'ask'; });
  100. ask.querySelector('#deleteyes').addEventListener('click', () => removeNote(rec.id));
  101. reader.replaceChildren(h('note-editor', {}, back, h('md-editor', {}, ta), h('p', { id: 'saveerror', class: 'message' }), h('footer', {}, del, ask)));
  102. }
  103. // the note open in the editor changed from OUTSIDE (a pull, another tab): show it, unless this tab is typing into it
  104. function refreshOpen() {
  105. const ta = $('#notetext');
  106. const rec = S.sel != null ? S.notes.get(S.sel) : null;
  107. if (!ta || !rec || rec.gone) { if (ta || (S.sel != null && !rec)) paintReader(); return; }
  108. if (ta.value !== rec.text && !rec.dirty) paintReader();
  109. }
  110. function select(id) {
  111. S.sel = id || null;
  112. history.replaceState({}, '', S.sel ? '/note/' + S.sel : '/');
  113. paintList();
  114. paintReader();
  115. }
  116. // ---- what the person does ---------------------------------------------------------------------------------------
  117. async function createNote() {
  118. const t = Date.now();
  119. const rec = { id: rand(), text: '', base: 0, at: t, dirty: true, fresh: true, gone: false, created: t };
  120. await put(rec);
  121. tell();
  122. select(rec.id);
  123. later();
  124. }
  125. async function edited(value) {
  126. const rec = S.notes.get(S.sel);
  127. if (!rec || rec.text === value) return;
  128. rec.text = value; rec.at = Date.now(); rec.dirty = true;
  129. await put(rec);
  130. tell();
  131. paintList();
  132. later();
  133. }
  134. async function removeNote(id) {
  135. const rec = S.notes.get(id);
  136. if (!rec) return;
  137. if (rec.fresh) await drop(id);
  138. else { rec.gone = true; rec.dirty = true; await put(rec); }
  139. tell();
  140. select(null);
  141. later();
  142. }
  143. // ---- the sync ---------------------------------------------------------------------------------------------------
  144. const later = () => { clearTimeout(S.timer); S.timer = setTimeout(() => sync(), 600); };
  145. const api = async (method, path, body) => {
  146. const res = await fetch(path, { method, credentials: 'same-origin', cache: 'no-store', headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined });
  147. if (res.status === 401) throw Object.assign(new Error('login'), { login: true });
  148. const json = await res.json().catch(() => null);
  149. if (!res.ok) throw Object.assign(new Error((json && json.error) || 'HTTP ' + res.status), { server: true });
  150. return json;
  151. };
  152. const take = (rec, r) => ({ ...rec, id: r.id, text: r.text, base: r.edited, at: r.edited, dirty: false, fresh: false, gone: false });
  153. async function pushOne(rec) {
  154. if (rec.gone) {
  155. const r = await api('POST', '/api/notes', { op: 'delete', id: rec.id, base: rec.base });
  156. if (r.kept) await put(take(rec, r)); // changed elsewhere meanwhile: the note stays
  157. else await drop(rec.id);
  158. return;
  159. }
  160. const sent = rec.text;
  161. 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 });
  162. const now = S.notes.get(rec.id) || rec;
  163. const typedMeanwhile = now.text !== sent;
  164. if (r.id !== rec.id) {
  165. await drop(rec.id);
  166. if (S.sel === rec.id) { S.sel = r.id; history.replaceState({}, '', '/note/' + r.id); }
  167. }
  168. if (typedMeanwhile) await put({ ...now, id: r.id, base: r.edited, fresh: false, dirty: true });
  169. else await put(take(now, r));
  170. 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 });
  171. }
  172. async function pull() {
  173. const { notes } = await api('GET', '/api/notes');
  174. const seen = new Set();
  175. for (const m of notes) {
  176. seen.add(m.id);
  177. const loc = S.notes.get(m.id);
  178. if (m.gone) { if (loc && !loc.dirty) await drop(m.id); continue; }
  179. if (loc && (loc.dirty || loc.base === m.edited)) continue;
  180. const one = await api('GET', '/api/notes?id=' + encodeURIComponent(m.id)).catch((e) => { if (e.server) return null; throw e; });
  181. if (!one) continue;
  182. const again = S.notes.get(m.id);
  183. if (again && again.dirty) continue; // typed while this was on its way
  184. await put({ id: one.id, text: one.text, base: one.edited, at: one.edited, dirty: false, fresh: false, gone: false, created: one.created });
  185. }
  186. for (const loc of [...S.notes.values()]) if (!loc.fresh && !loc.dirty && !seen.has(loc.id)) await drop(loc.id);
  187. }
  188. async function syncOnce() {
  189. if (!S.db || navigator.onLine === false) throw new Error('offline');
  190. try {
  191. for (const rec of [...S.notes.values()].filter((r) => r.dirty)) await pushOne(rec);
  192. await pull();
  193. setState('');
  194. } catch (e) {
  195. if (e.login) setState('Your login has expired — log in again to sync your notes.');
  196. else if (e.server) setState('Could not sync: ' + e.message);
  197. else setState(OFFLINE);
  198. throw e;
  199. }
  200. }
  201. // one sync at a time across all tabs (Web Locks); a failed one just waits for the next try
  202. async function sync() {
  203. const gen = S.gen;
  204. const run = async () => {
  205. try { await syncOnce(); } catch (e) { /* shown in the state line */ }
  206. S.loaded = true;
  207. if (gen !== S.gen || !S.app) return;
  208. tell();
  209. paintList();
  210. refreshOpen();
  211. if (S.sel != null && !S.notes.get(S.sel)) paintReader();
  212. };
  213. if (navigator.locks) await navigator.locks.request('notes-sync', run); else await run();
  214. }
  215. // ---- offline pages: the service worker and the copy of the page it keeps ---------------------------------------
  216. async function keepShell() {
  217. if (!('serviceWorker' in navigator) || !window.caches) return;
  218. try {
  219. await navigator.serviceWorker.register('/sw.js');
  220. await navigator.serviceWorker.ready;
  221. const cache = await caches.open(CACHE);
  222. const page = await fetch('/', { credentials: 'same-origin', cache: 'no-store' });
  223. if (page.ok && !page.redirected) await cache.put('/shell', page);
  224. const urls = performance.getEntriesByType('resource').map((e) => e.name).filter((u) => u.startsWith(location.origin) && !u.includes('/api/'));
  225. urls.push(location.origin + '/md-editor.js', location.origin + '/notes-offline.js', location.origin + '/login.js');
  226. for (const u of new Set(urls)) { if (!(await cache.match(u))) { try { await cache.add(u); } catch (e) { /* not kept */ } } }
  227. } catch (e) { /* no service worker here: online use is unchanged */ }
  228. }
  229. // ---- mount / unmount ----------------------------------------------------------------------------------------------
  230. async function whoAmI() {
  231. try {
  232. const j = await (await fetch('/api/me', { credentials: 'same-origin', cache: 'no-store' })).json();
  233. if (j && j.user) { localStorage.setItem('notes.user', j.user); return j.user; }
  234. return null; // online, and not logged in
  235. } catch (e) { return localStorage.getItem('notes.user'); } // offline: the last user of this browser
  236. }
  237. async function mount(el) {
  238. const gen = ++S.gen;
  239. S.app = el; S.loaded = false;
  240. const user = await whoAmI();
  241. if (gen !== S.gen || S.app !== el) return;
  242. if (!user) { el.replaceChildren(h('p', { class: 'muted', text: 'Log in with ident (top right) to see your notes.' })); return; }
  243. S.user = user;
  244. S.db = await openDb(user);
  245. await reload();
  246. const m = location.pathname.match(/^\/note\/([^/]+)$/);
  247. S.sel = m ? decodeURIComponent(m[1]) : null;
  248. build(); paintList(); paintReader();
  249. if (window.BroadcastChannel) {
  250. S.bc = new BroadcastChannel('notes-' + user);
  251. S.bc.onmessage = async () => { await reload(); paintList(); refreshOpen(); };
  252. }
  253. el.addEventListener('click', (e) => {
  254. const a = e.target.closest && e.target.closest('a[data-note]');
  255. if (!a || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
  256. e.preventDefault(); // claimed: the framework does not navigate
  257. select(a.dataset.note);
  258. });
  259. S.poll = setInterval(() => { if (document.visibilityState === 'visible') sync(); }, 10000);
  260. sync();
  261. keepShell();
  262. }
  263. function unmount() {
  264. S.gen++;
  265. clearInterval(S.poll); clearTimeout(S.timer);
  266. if (S.bc) S.bc.close();
  267. if (S.db) S.db.close();
  268. Object.assign(S, { app: null, db: null, bc: null, user: null, notes: new Map(), sel: null });
  269. }
  270. function check() {
  271. const el = document.getElementById('notesapp');
  272. if (el && el !== S.app) { if (S.app) unmount(); mount(el); }
  273. else if (!el && S.app) unmount();
  274. }
  275. window.addEventListener('online', () => sync());
  276. document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && S.app) sync(); });
  277. // LOG OUT: what is not yet on the server goes first, then this browser forgets the notes (a shared computer)
  278. document.addEventListener('click', async (e) => {
  279. const b = e.target.closest && e.target.closest('#logout');
  280. if (!b || b.__passed || !S.db) return;
  281. e.stopImmediatePropagation(); e.preventDefault();
  282. let clean = false;
  283. try { await syncOnce(); clean = ![...S.notes.values()].some((r) => r.dirty); } catch (err) { /* offline: keep the notes */ }
  284. if (clean) {
  285. const name = 'notes-' + S.user;
  286. const user = S.user;
  287. unmount();
  288. await new Promise((ok) => { const r = indexedDB.deleteDatabase(name); r.onsuccess = r.onerror = r.onblocked = () => ok(); });
  289. localStorage.removeItem('notes.user');
  290. if (window.caches) await caches.delete(CACHE);
  291. }
  292. b.__passed = true; b.click();
  293. }, true);
  294. window.__notesOffline = { check, sync: () => sync(), state: S };
  295. new MutationObserver(check).observe(document.documentElement, { subtree: true, childList: true });
  296. check();
  297. })();

Branches

Latest commits

  • 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