1/* 2 * notes-store.js â the visitor's own notes, kept in their own browser. 3 * 4 * IT-Journey is a static site, so there is no account and no server to hold a 5 * reader's work. This module is the whole persistence layer: one versioned blob 6 * in localStorage that both the site-wide clipper (assets/js/notes-clipper.js) 7 * and the workbench (assets/js/notes-workbench.js) read and write through. 8 * 9 * Three kinds of thing live on the board, sharing one id space so the board's 10 * order array never has to branch on type: 11 * 12 * n_⦠note â markdown the reader wrote here 13 * c_⦠clip â a code block or selection captured from a page on this site 14 * p_⦠pin â a reference to a public page (a note, a quest, anything) 15 * 16 * Public notes are NOT stored here. Those are the markdown files in 17 * pages/_notes/ that Jekyll renders for everyone; a pin only points at one. 18 * 19 * Every read and write is wrapped: private-mode browsers throw on access, and a 20 * reader whose storage is full or blocked still gets a working page. Writes are 21 * debounced, and a `storage` listener keeps two open tabs in agreement. 22 */ 23(function () { 24 'use strict'; 25 26 var KEY = 'itj.notes.workbench'; 27 var VERSION = 1; 28 var WRITE_DELAY = 300; 29 30 // localStorage is ~5MB in practice. Warn well before the ceiling so a reader 31 // can export rather than discovering the limit when a save silently fails. 32 var QUOTA_WARN_BYTES = 4 * 1024 * 1024; 33 34 // ---- shape --------------------------------------------------------------- 35 36 function blank() { 37 return { 38 v: VERSION, 39 notes: {}, 40 clips: {}, 41 pins: {}, 42 board: { order: [], sizes: {}, collapsed: {} }, 43 prefs: { density: 'comfortable', sort: 'manual', showLibrary: true, lastOpenNoteId: null } 44 }; 45 } 46 47 // Drop any card whose id would be dangerous to use as a property name later. 48 // JSON.parse itself is safe, but the ids become bracket keys all over this 49 // file, so they are filtered once here rather than guarded at every use. 50 function sanitizeBucket(raw) { 51 var out = {}; 52 if (!raw || typeof raw !== 'object') return out;
53 Object.keys(raw).forEach(function (id) { 54 if (!safeKey(id)) return; 55 if (!Object.prototype.hasOwnProperty.call(raw, id)) return; 56 if (raw[id] && typeof raw[id] === 'object') out[id] = raw[id]; 57 }); 58 return out; 59 } 60 61 // Fill in anything a older or hand-edited blob is missing, so callers can 62 // always assume every branch exists. 63 function normalize(raw) { 64 var base = blank(); 65 if (!raw || typeof raw !== 'object') return base; 66 base.notes = sanitizeBucket(raw.notes); 67 base.clips = sanitizeBucket(raw.clips); 68 base.pins = sanitizeBucket(raw.pins); 69 if (raw.board && typeof raw.board === 'object') { 70 base.board.order = Array.isArray(raw.board.order) ? raw.board.order.slice() : []; 71 base.board.sizes = raw.board.sizes && typeof raw.board.sizes === 'object' ? raw.board.sizes : {}; 72 base.board.collapsed = raw.board.collapsed && typeof raw.board.collapsed === 'object' ? raw.board.collapsed : {}; 73 } 74 if (raw.prefs && typeof raw.prefs === 'object') { 75 for (var k in base.prefs) { 76 if (Object.prototype.hasOwnProperty.call(raw.prefs, k)) base.prefs[k] = raw.prefs[k]; 77 } 78 } 79 // Drop ids in the order list that no longer have a card, and append any card 80 // the list is missing (a clip added from another tab, say). 81 base.board.order = base.board.order.filter(function (id) { return !!lookup(base, id); }); 82 ['notes', 'clips', 'pins'].forEach(function (bucket) { 83 Object.keys(base[bucket]).forEach(function (id) { 84 if (base.board.order.indexOf(id) === -1) base.board.order.push(id); 85 }); 86 }); 87 return base; 88 } 89 90 // Keys that would reach Object.prototype through a bracket assignment. An 91 // imported file is just JSON a reader picked off disk, so anything derived 92 // from one is treated as hostile before it is written anywhere by name. 93 function safeKey(k) { 94 return k !== '__proto__' && k !== 'constructor' && k !== 'prototype'; 95 } 96 97 function bucketFor(id) { 98 if (!id) return null; 99 if (id.charAt(0) === 'n') return 'notes'; 100 if (id.charAt(0) === 'c') return 'clips'; 101 if (id.charAt(0) === 'p') return 'pins'; 102 return null; 103 } 104 105 function lookup(state, id) { 106 var b = bucketFor(id); 107 return b && state[b] ? state[b][id] : null; 108 } 109 110 // ---- storage ------------------------------------------------------------- 111 112 var available = true; 113 var state = blank(); 114 115 function read() { 116 try { 117 var raw = localStorage.getItem(KEY); 118 if (!raw) return blank(); 119 return normalize(JSON.parse(raw)); 120 } catch (e) { 121 available = false; 122 return blank(); 123 } 124 } 125 126 var writeTimer = null; 127 var listeners = []; 128 var lastWarnedBytes = 0; 129 130 function flush() { 131 writeTimer = null; 132 if (!available) return; 133 var payload; 134 try { 135 payload = JSON.stringify(state); 136 } catch (e) { 137 return; 138 } 139 try { 140 localStorage.setItem(KEY, payload); 141 } catch (e) { 142 // Quota exceeded, or storage turned off mid-session. Keep the in-memory 143 // state so the current page still works, and tell anyone listening. 144 available = false; 145 emit('error', { reason: 'write-failed', bytes: payload.length }); 146 return; 147 } 148 if (payload.length > QUOTA_WARN_BYTES && payload.length > lastWarnedBytes * 1.1) { 149 lastWarnedBytes = payload.length; 150 emit('quota', { bytes: payload.length, limit: QUOTA_WARN_BYTES }); 151 } 152 } 153 154 function persist() { 155 if (writeTimer) clearTimeout(writeTimer); 156 writeTimer = setTimeout(flush, WRITE_DELAY); 157 } 158 159 function emit(kind, detail) { 160 var payload = { kind: kind, detail: detail || null, state: state }; 161 listeners.forEach(function (fn) { 162 try { fn(payload); } catch (e) { /* a bad listener must not stop the rest */ } 163 }); 164 try { 165 document.dispatchEvent(new CustomEvent('itj-notes:change', { detail: payload })); 166 } catch (e) { /* very old browser */ } 167 } 168 169 // ---- ids ----------------------------------------------------------------- 170 171 var seq = 0; 172 function makeId(prefix) { 173 seq += 1; 174 return prefix + '_' + Date.now().toString(36) + seq.toString(36) + Math.floor(Math.random() * 1296).toString(36); 175 } 176 177 function nowIso() { 178 return new Date().toISOString(); 179 } 180 181 // ---- mutations ----------------------------------------------------------- 182 183 function place(id, atFront) { 184 if (state.board.order.indexOf(id) !== -1) return; 185 if (atFront) state.board.order.unshift(id); 186 else state.board.order.push(id); 187 } 188 189 function addNote(fields) { 190 var f = fields || {}; 191 var id = makeId('n'); 192 state.notes[id] = { 193 id: id, 194 title: f.title || 'Untitled note', 195 body: f.body || '', 196 tags: Array.isArray(f.tags) ? f.tags.slice() : [], 197 color: f.color || 'default', 198 created: f.created || nowIso(), 199 updated: f.updated || nowIso() 200 }; 201 place(id, true); 202 persist(); 203 emit('add', { id: id, type: 'note' }); 204 return id; 205 } 206 207 function addClip(fields) { 208 var f = fields || {}; 209 var id = makeId('c'); 210 state.clips[id] = { 211 id: id, 212 kind: f.kind === 'code' ? 'code' : 'text', 213 text: f.text || '', 214 lang: f.lang || '', 215 heading: f.heading || '', 216 sourceUrl: f.sourceUrl || '', 217 sourceTitle: f.sourceTitle || '', 218 sourceCollection: f.sourceCollection || '', 219 created: f.created || nowIso() 220 }; 221 place(id, true); 222 persist(); 223 emit('add', { id: id, type: 'clip' }); 224 return id; 225 } 226 227 function addPin(fields) { 228 var f = fields || {}; 229 if (!f.url) return null; 230 // A page is pinned once. Re-pinning surfaces the card already on the board. 231 var existing = null;
232 Object.keys(state.pins).forEach(function (id) { 233 if (state.pins[id].url === f.url) existing = id; 234 }); 235 if (existing) return existing; 236 var id = makeId('p'); 237 state.pins[id] = { 238 id: id, 239 url: f.url, 240 title: f.title || f.url, 241 collection: f.collection || '', 242 description: f.description || '', 243 added: f.added || nowIso() 244 }; 245 place(id, true); 246 persist(); 247 emit('add', { id: id, type: 'pin' }); 248 return id; 249 } 250 251 function update(id, patch) { 252 var item = lookup(state, id); 253 if (!item || !patch) return false; 254 Object.keys(patch).forEach(function (k) { 255 if (k === 'id' || !safeKey(k)) return; 256 if (!Object.prototype.hasOwnProperty.call(patch, k)) return; 257 item[k] = patch[k]; 258 }); 259 if (bucketFor(id) === 'notes') item.updated = nowIso(); 260 persist(); 261 emit('update', { id: id }); 262 return true; 263 } 264 265 function remove(id) { 266 var b = bucketFor(id); 267 if (!b || !state[b][id]) return false; 268 delete state[b][id]; 269 var at = state.board.order.indexOf(id); 270 if (at !== -1) state.board.order.splice(at, 1); 271 delete state.board.sizes[id]; 272 delete state.board.collapsed[id]; 273 persist(); 274 emit('remove', { id: id }); 275 return true; 276 } 277 278 // Move `id` so it sits where `beforeId` currently is. With no `beforeId` the 279 // card goes to the end. Returns the new order. 280 function reorder(id, beforeId) { 281 var order = state.board.order; 282 var from = order.indexOf(id); 283 if (from === -1) return order; 284 order.splice(from, 1); 285 var to = beforeId ? order.indexOf(beforeId) : -1; 286 if (to === -1) order.push(id); 287 else order.splice(to, 0, id); 288 persist(); 289 emit('reorder', { id: id }); 290 return order; 291 } 292 293 // Replace the whole arrangement at once â used to undo a keyboard move. 294 function setOrder(order) { 295 if (!Array.isArray(order)) return false; 296 var kept = order.filter(function (id) { return !!lookup(state, id); }); 297 Object.keys(state.notes).concat(Object.keys(state.clips), Object.keys(state.pins)) 298 .forEach(function (id) { if (kept.indexOf(id) === -1) kept.push(id); }); 299 state.board.order = kept; 300 persist(); 301 emit('reorder', { id: null }); 302 return true; 303 } 304 305 function setSize(id, cols, rows) { 306 if (!safeKey(id)) return; 307 state.board.sizes[id] = [cols, rows]; 308 persist(); 309 emit('resize', { id: id }); 310 } 311 312 function setCollapsed(id, collapsed) { 313 if (!safeKey(id)) return; 314 if (collapsed) state.board.collapsed[id] = true; 315 else delete state.board.collapsed[id]; 316 persist(); 317 emit('collapse', { id: id }); 318 } 319 320 function setPref(key, value) { 321 if (!safeKey(key)) return; 322 state.prefs[key] = value; 323 persist(); 324 emit('prefs', { key: key }); 325 } 326 327 function reset() { 328 state = blank(); 329 available = true; 330 try { localStorage.removeItem(KEY); } catch (e) { /* nothing to clear */ } 331 emit('reset', null); 332 } 333 334 // ---- transfer ------------------------------------------------------------ 335 336 function exportJson() { 337 return JSON.stringify(state, null, 2); 338 } 339 340 // Merge an exported blob into the current one. Ids are regenerated so a 341 // reader can import the same file twice, or merge two machines, without one 342 // set of cards silently overwriting the other. 343 function importJson(text, opts) { 344 var incoming; 345 try { 346 incoming = normalize(JSON.parse(text)); 347 } catch (e) { 348 return { ok: false, error: 'That file is not valid workbench JSON.' }; 349 } 350 if (opts && opts.replace) state = blank(); 351 var counts = { notes: 0, clips: 0, pins: 0 }; 352 incoming.board.order.forEach(function (oldId) { 353 var item = lookup(incoming, oldId); 354 if (!item) return; 355 var b = bucketFor(oldId); 356 if (b === 'notes') { addNote(item); counts.notes += 1; } 357 else if (b === 'clips') { addClip(item); counts.clips += 1; } 358 else if (b === 'pins' && addPin(item)) { counts.pins += 1; } 359 }); 360 persist(); 361 emit('import', counts); 362 return { ok: true, counts: counts }; 363 } 364 365 // ---- cross-tab ----------------------------------------------------------- 366 367 window.addEventListener('storage', function (ev) { 368 if (ev.key !== KEY) return; 369 state = read(); 370 emit('external', null); 371 }); 372 373 // ---- boot ---------------------------------------------------------------- 374 375 state = read(); 376 377 window.ITJNotes = { 378 KEY: KEY, 379 VERSION: VERSION, 380 isAvailable: function () { return available; }, 381 get: function () { return state; }, 382 count: function () { 383 return Object.keys(state.notes).length + Object.keys(state.clips).length + Object.keys(state.pins).length; 384 }, 385 item: function (id) { return lookup(state, id); }, 386 typeOf: bucketFor, 387 addNote: addNote, 388 addClip: addClip, 389 addPin: addPin, 390 update: update, 391 remove: remove, 392 reorder: reorder, 393 setOrder: setOrder, 394 setSize: setSize, 395 setCollapsed: setCollapsed, 396 setPref: setPref, 397 reset: reset, 398 exportJson: exportJson, 399 importJson: importJson, 400 subscribe: function (fn) { 401 if (typeof fn !== 'function') return function () {}; 402 listeners.push(fn); 403 return function () { 404 var at = listeners.indexOf(fn); 405 if (at !== -1) listeners.splice(at, 1); 406 }; 407 } 408 }; 409}());
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.