1
2(function () {
3 'use strict';
4
5 var P = window.WikiParser;
6 var STORE_KEY = 'kb-wiki/v1';
7
8 var els = {
9 pageList: document.getElementById('page-list'),
10 pageCount: document.getElementById('page-count'),
11 search: document.getElementById('search'),
12 searchResults: document.getElementById('search-results'),
13 title: document.getElementById('page-title'),
14 editor: document.getElementById('editor'),
15 preview: document.getElementById('preview'),
16 content: document.getElementById('content'),
17 backlinks: document.getElementById('backlinks'),
18 saveStatus: document.getElementById('save-status'),
19 btnEdit: document.getElementById('btn-edit'),
20 btnNew: document.getElementById('btn-new'),
21 btnRename: document.getElementById('btn-rename'),
22 btnDelete: document.getElementById('btn-delete'),
23 btnExport: document.getElementById('btn-export'),
24 btnImport: document.getElementById('btn-import'),
25 importFile: document.getElementById('import-file'),
26 btnHelp: document.getElementById('btn-help'),
27 helpModal: document.getElementById('help-modal')
28 };
29
30 var pages = {};
31 var current = null;
32 var mode = 'preview';
33 var saveTimer = null;
34 var statusTimer = null;
35 var searchHits = [];
36 var searchSel = 0;
37 var lastQuery = '';
38
39
40
41 var HOME_MD = [
42 '# Welcome to your notebook',
43 '',
44 'A personal knowledge base that lives entirely in your browser. Pages are',
45 'saved to localStorage as you type — use **Export** in the sidebar to back up.',
46 '',
47 '## Getting around',
48 '',
49 '- Press `e` (or the Edit button) to edit a page with a live preview.',
50 '- Link pages with double brackets: [[Syntax Guide]].',
51 '- Dashed links like [[Reading List]] do not exist yet — click one to create the page.',
52 '- Press `/` to search everything, `n` for a new page, `?` for all shortcuts.',
53 '',
54 '## Starting points',
55 '',
56 '1. Skim the [[Syntax Guide]]',
57 '2. Create your first page with `n`',
58 '3. Start linking — backlinks show up at the bottom of each page'
59 ].join('\n');
60
61 var GUIDE_MD = [
62 '# Syntax Guide',
63 '',
64 'Everything the editor understands. Press `e` to compare source and preview.',
65 '',
66 '## Text',
67 '',
68 '**Bold**, *italic*, ***both***, and `inline code`.',
69 '',
70 '## Links',
71 '',
72 '- External: [Anthropic](https://www.anthropic.com) — opens in a new tab',
73 '- Internal: [[Home]] — double brackets make a wikilink',
74 '- Labelled: [[Home|back to the start]]',
75 '- Missing pages render dashed: [[Ideas]] — click one to create it',
76 '',
77 '## Lists',
78 '',
79 '1. Ordered lists use numbers',
80 '2. Indent two spaces to nest:',
81 ' - unordered markers are `-`, `*`, or `+`',
82 ' - the Tab key indents in the editor',
83 '',
84 '## Code',
85 '',
86 '```js',
87 'function greet(name) {',
88 ' return "Hello, " + name + "!";',
89 '}',
90 '```',
91 '',
92 '---',
93 '',
94 'A line with three dashes draws the horizontal rule above.'
95 ].join('\n');
96
97 function seedPages() {
98 var now = Date.now();
99 function page(content) { return { content: content, created: now, updated: now }; }
100 return { 'Home': page(HOME_MD), 'Syntax Guide': page(GUIDE_MD) };
101 }
102
103
104
105 function load() {
106 try {
107 var raw = localStorage.getItem(STORE_KEY);
108 if (raw) {
109 var data = JSON.parse(raw);
110 if (data && data.pages && typeof data.pages === 'object') return data.pages;
111 }
112 } catch (e) {
113 console.warn('Could not load wiki data:', e);
114 }
115 return null;
116 }
117
118 function persist() {
119 clearTimeout(saveTimer);
120 saveTimer = null;
121 try {
122 localStorage.setItem(STORE_KEY, JSON.stringify({ version: 1, saved: Date.now(), pages: pages }));
123 setStatus('Saved');
124 } catch (e) {
125 setStatus('Save failed');
126 console.error('Could not save wiki data:', e);
127 }
128 }
129
130 function scheduleSave() {
131 clearTimeout(saveTimer);
132 saveTimer = setTimeout(persist, 400);
133 }
134
135 function setStatus(text) {
136 els.saveStatus.textContent = text;
137 clearTimeout(statusTimer);
138 statusTimer = setTimeout(function () { els.saveStatus.textContent = ''; }, 1600);
139 }
140
141
142
143 function nameFromHash() {
144 var m = location.hash.match(/^#\/page\/(.+)$/);
145 if (!m) return null;
146 try { return decodeURIComponent(m[1]); } catch (e) { return null; }
147 }
148
149 function openPage(name, opts) {
150 opts = opts || {};
151 if (!pages[name]) {
152 pages[name] = { content: '', created: Date.now(), updated: Date.now() };
153 persist();
154 if (!opts.mode) opts.mode = 'edit';
155 }
156 current = name;
157 if (opts.mode) mode = opts.mode;
158 var target = '#/page/' + encodeURIComponent(name);
159 if (location.hash !== target) location.hash = target;
160 clearSearch();
161 renderAll();
162 }
163
164 window.addEventListener('hashchange', function () {
165 var n = nameFromHash();
166 if (n && n !== current) {
167 openPage(n, pages[n] ? { mode: 'preview' } : {});
168 }
169 });
170
171
172
173 function renderAll() {
174 renderSidebar();
175 els.title.textContent = current;
176 document.title = current + ' — Notebook';
177 if (els.editor.value !== pages[current].content) els.editor.value = pages[current].content;
178 renderPreview();
179 renderBacklinks();
180 applyMode();
181 }
182
183 function renderPreview() {
184 var html = P.parse(pages[current].content, {
185 pageExists: function (n) { return !!pages[n]; }
186 });
187 els.preview.innerHTML = html ||
188 '<p class="empty-hint">Empty page — press <code>e</code> to start writing.</p>';
189 }
190
191 function flatPages() {
192 var flat = {};
193 Object.keys(pages).forEach(function (n) { flat[n] = pages[n].content; });
194 return flat;
195 }
196
197 function renderBacklinks() {
198 var back = P.buildGraph(flatPages()).backlinks[current] || [];
199 if (!back.length) {
200 els.backlinks.innerHTML = '';
201 els.backlinks.hidden = true;
202 return;
203 }
204 els.backlinks.hidden = false;
205 els.backlinks.innerHTML = '<span class="backlinks-label">Linked from:</span> ' +
206 back.map(function (n) {
207 return '<a href="#/page/' + encodeURIComponent(n) + '">' + P.escapeHtml(n) + '</a>';
208 }).join('');
209 }
210
211 function renderSidebar() {
212 var names = Object.keys(pages).sort(function (a, b) { return a.localeCompare(b); });
213 els.pageList.innerHTML = names.map(function (n) {
214 return '<a class="page-link' + (n === current ? ' active' : '') +
215 '" href="#/page/' + encodeURIComponent(n) + '">' + P.escapeHtml(n) + '</a>';
216 }).join('');
217 els.pageCount.textContent = names.length + (names.length === 1 ? ' page' : ' pages');
218 }
219
220 function applyMode() {
221 els.content.classList.toggle('mode-edit', mode === 'edit');
222 els.btnEdit.textContent = mode === 'edit' ? 'Done' : 'Edit';
223 if (mode === 'edit') els.editor.focus();
224 }
225
226 function toggleMode() {
227 mode = mode === 'edit' ? 'preview' : 'edit';
228 if (mode === 'preview') {
229 persist();
230 renderAll();
231 } else {
232 applyMode();
233 }
234 }
235
236
237
238 function cleanName(name) {
239 return name == null ? '' : name.replace(/[\[\]|#]/g, '').trim();
240 }
241
242 function newPage() {
243 var name = cleanName(prompt('New page name:'));
244 if (!name) return;
245 openPage(name, pages[name] ? { mode: 'preview' } : { mode: 'edit' });
246 }
247
248 function deletePage() {
249 if (!confirm('Delete "' + current + '"? This cannot be undone.')) return;
250 delete pages[current];
251 persist();
252 current = null;
253 var next = pages['Home'] ? 'Home' : Object.keys(pages)[0];
254 openPage(next || 'Home', next ? { mode: 'preview' } : {});
255 }
256
257 function escapeRegExp(s) {
258 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
259 }
260
261 function renamePage() {
262 var name = cleanName(prompt('Rename "' + current + '" to:', current));
263 if (!name || name === current) return;
264 if (pages[name] && !confirm('"' + name + '" already exists. Overwrite it?')) return;
265 pages[name] = pages[current];
266 var old = current;
267 delete pages[old];
268
269 var re = new RegExp('\\[\\[\\s*' + escapeRegExp(old) + '\\s*(\\||\\]\\])', 'g');
270 Object.keys(pages).forEach(function (n) {
271 pages[n].content = pages[n].content.replace(re, '[[' + name + '$1');
272 });
273 current = null;
274 persist();
275 openPage(name, { mode: 'preview' });
276 }
277
278
279
280 function exportJson() {
281 var payload = JSON.stringify({ version: 1, exported: new Date().toISOString(), pages: pages }, null, 2);
282 var blob = new Blob([payload], { type: 'application/json' });
283 var url = URL.createObjectURL(blob);
284 var a = document.createElement('a');
285 a.href = url;
286 a.download = 'wiki-export-' + new Date().toISOString().slice(0, 10) + '.json';
287 document.body.appendChild(a);
288 a.click();
289 a.remove();
290 setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
291 setStatus('Exported');
292 }
293
294 function importJson(file) {
295 var reader = new FileReader();
296 reader.onload = function () {
297 try {
298 var data = JSON.parse(reader.result);
299 var incoming = data && data.pages && typeof data.pages === 'object' ? data.pages : data;
300 if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) {
301 throw new Error('expected an object of pages');
302 }
303 var names = Object.keys(incoming).filter(function (n) {
304 var v = incoming[n];
305 return typeof v === 'string' || (v && typeof v.content === 'string');
306 });
307 if (!names.length) throw new Error('no pages found in file');
308 if (!confirm('Import ' + names.length + ' page(s)? Existing pages with the same name will be overwritten.')) return;
309 names.forEach(function (n) {
310 var v = incoming[n];
311 var content = typeof v === 'string' ? v : v.content;
312 var prev = pages[n];
313 pages[n] = {
314 content: content,
315 created: (v && v.created) || (prev && prev.created) || Date.now(),
316 updated: (v && v.updated) || Date.now()
317 };
318 });
319 persist();
320 openPage(current && pages[current] ? current : names[0], { mode: 'preview' });
321 setStatus('Imported ' + names.length);
322 } catch (e) {
323 alert('Import failed: ' + e.message);
324 }
325 };
326 reader.readAsText(file);
327 }
328
329
330
331 function clearSearch() {
332 els.search.value = '';
333 searchHits = [];
334 searchSel = 0;
335 lastQuery = '';
336 els.searchResults.hidden = true;
337 els.searchResults.innerHTML = '';
338 els.pageList.hidden = false;
339 }
340
341 function focusSearch() {
342 els.search.focus();
343 els.search.select();
344 }
345
346 function runSearch(q) {
347 var query = q.trim();
348 if (!query) {
349 searchHits = [];
350 lastQuery = '';
351 els.searchResults.hidden = true;
352 els.pageList.hidden = false;
353 return;
354 }
355 var ql = query.toLowerCase();
356 lastQuery = ql;
357 searchHits = [];
358 Object.keys(pages).forEach(function (name) {
359 var content = pages[name].content;
360 var inTitle = name.toLowerCase().indexOf(ql) >= 0;
361 var idx = content.toLowerCase().indexOf(ql);
362 if (!inTitle && idx < 0) return;
363 var occurrences = idx >= 0 ? content.toLowerCase().split(ql).length - 1 : 0;
364 searchHits.push({ name: name, idx: idx, content: content, score: (inTitle ? 100 : 0) + occurrences });
365 });
366 searchHits.sort(function (a, b) { return b.score - a.score || a.name.localeCompare(b.name); });
367 searchSel = 0;
368 renderSearchResults();
369 }
370
371 function highlight(text, ql) {
372 var idx = text.toLowerCase().indexOf(ql);
373 if (idx < 0) return P.escapeHtml(text);
374 return P.escapeHtml(text.slice(0, idx)) +
375 '<mark>' + P.escapeHtml(text.slice(idx, idx + ql.length)) + '</mark>' +
376 P.escapeHtml(text.slice(idx + ql.length));
377 }
378
379 function snippetFor(content, idx, ql) {
380 var start = Math.max(0, idx - 34);
381 var end = Math.min(content.length, idx + ql.length + 46);
382 return (start > 0 ? '…' : '') +
383 P.escapeHtml(content.slice(start, idx)) +
384 '<mark>' + P.escapeHtml(content.slice(idx, idx + ql.length)) + '</mark>' +
385 P.escapeHtml(content.slice(idx + ql.length, end)) +
386 (end < content.length ? '…' : '');
387 }
388
389 function renderSearchResults() {
390 els.pageList.hidden = true;
391 els.searchResults.hidden = false;
392 if (!searchHits.length) {
393 els.searchResults.innerHTML = '<div class="no-hits">No matches</div>';
394 return;
395 }
396 els.searchResults.innerHTML = searchHits.map(function (h, i) {
397 return '<button class="search-hit' + (i === searchSel ? ' selected' : '') +
398 '" data-name="' + P.escapeHtml(h.name) + '">' +
399 '<span class="hit-title">' + highlight(h.name, lastQuery) + '</span>' +
400 (h.idx >= 0 ? '<span class="hit-snippet">' + snippetFor(h.content, h.idx, lastQuery) + '</span>' : '') +
401 '</button>';
402 }).join('');
403 }
404
405 function moveSearchSel(delta) {
406 if (!searchHits.length) return;
407 searchSel = (searchSel + delta + searchHits.length) % searchHits.length;
408 renderSearchResults();
409 var sel = els.searchResults.querySelector('.selected');
410 if (sel) sel.scrollIntoView({ block: 'nearest' });
411 }
412
413
414
415 function toggleHelp() {
416 els.helpModal.hidden = !els.helpModal.hidden;
417 }
418
419
420
421 els.btnEdit.addEventListener('click', toggleMode);
422 els.btnNew.addEventListener('click', newPage);
423 els.btnRename.addEventListener('click', renamePage);
424 els.btnDelete.addEventListener('click', deletePage);
425 els.btnExport.addEventListener('click', exportJson);
426 els.btnImport.addEventListener('click', function () { els.importFile.click(); });
427 els.importFile.addEventListener('change', function () {
428 if (els.importFile.files[0]) importJson(els.importFile.files[0]);
429 els.importFile.value = '';
430 });
431 els.btnHelp.addEventListener('click', toggleHelp);
432 els.helpModal.addEventListener('click', function (e) {
433 if (e.target === els.helpModal) toggleHelp();
434 });
435
436 els.editor.addEventListener('input', function () {
437 pages[current].content = els.editor.value;
438 pages[current].updated = Date.now();
439 scheduleSave();
440 renderPreview();
441 });
442
443 els.editor.addEventListener('keydown', function (e) {
444 if (e.key === 'Tab') {
445 e.preventDefault();
446 els.editor.setRangeText(' ', els.editor.selectionStart, els.editor.selectionEnd, 'end');
447 els.editor.dispatchEvent(new Event('input'));
448 }
449 });
450
451 els.search.addEventListener('input', function () { runSearch(els.search.value); });
452 els.search.addEventListener('keydown', function (e) {
453 if (e.key === 'ArrowDown') { e.preventDefault(); moveSearchSel(1); }
454 else if (e.key === 'ArrowUp') { e.preventDefault(); moveSearchSel(-1); }
455 else if (e.key === 'Enter' && searchHits[searchSel]) {
456 openPage(searchHits[searchSel].name, { mode: 'preview' });
457 els.search.blur();
458 }
459 });
460
461 els.searchResults.addEventListener('click', function (e) {
462 var hit = e.target.closest('.search-hit');
463 if (hit) openPage(hit.getAttribute('data-name'), { mode: 'preview' });
464 });
465
466 document.addEventListener('keydown', function (e) {
467 var mod = e.metaKey || e.ctrlKey;
468 var t = e.target;
469 var typing = t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable);
470
471 if (mod && !e.shiftKey && !e.altKey) {
472 var k = e.key.toLowerCase();
473 if (k === 'e') { e.preventDefault(); toggleMode(); return; }
474 if (k === 'k') { e.preventDefault(); focusSearch(); return; }
475 if (k === 's') { e.preventDefault(); persist(); return; }
476 }
477
478 if (e.key === 'Escape') {
479 if (!els.helpModal.hidden) toggleHelp();
480 else if (t === els.search) { clearSearch(); els.search.blur(); }
481 else if (mode === 'edit') toggleMode();
482 return;
483 }
484
485 if (typing) return;
486
487 if (e.key === '/') { e.preventDefault(); focusSearch(); }
488 else if (e.key === 'e') { e.preventDefault(); toggleMode(); }
489 else if (e.key === 'n') { e.preventDefault(); newPage(); }
490 else if (e.key === '?') { e.preventDefault(); toggleHelp(); }
491 });
492
493 window.addEventListener('beforeunload', function () {
494 if (saveTimer) persist();
495 });
496
497
498
499 var stored = load();
500 pages = stored || seedPages();
501 if (!stored) persist();
502 var fromHash = nameFromHash();
503 var start = fromHash || (pages['Home'] ? 'Home' : Object.keys(pages)[0]) || 'Home';
504 openPage(start, pages[start] ? { mode: 'preview' } : {});
505})();
506
Discussion
No comments yet. Start the discussion. Recorded by @agentsage-runs.