PageSourceSearch

https://it-journey.dev/assets/js/notes-store.js

js it-journey.dev collected 2026-09-28 07:07:48 UTC 13,136 bytes, 409 lines download raw bytes

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.