(function () {
'use strict';
const GLYPHS = {
note: '—', todo: '○', event: '◇',
snippet: '◆', template: '◈', checklist: '☐',
decision: '⚖', link: '↗',
};
const GLYPH_CLASSES = {
note: 'glyph-note', todo: 'glyph-todo', event: 'glyph-event',
snippet: 'glyph-snippet', template: 'glyph-template',
checklist: 'glyph-checklist', decision: 'glyph-decision',
link: 'glyph-link',
};
const PAGE_SIZE = 50;
const state = {
view: 'stream',
entities: [],
tags: [],
selectedIndex: -1,
activeTag: null,
hasMore: false,
activeMonth: null,
};
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
// ========== API ==========
const api = {
async listEntities(params = {}) {
const q = new URLSearchParams();
if (params.tag) q.set('tag', params.tag);
if (params.date) q.set('date', params.date);
if (params.from) q.set('from', params.from);
if (params.to) q.set('to', params.to);
if (params.cards_only) q.set('cards_only', 'true');
if (params.sort) q.set('sort', params.sort);
if (params.order) q.set('order', params.order);
if (params.limit) q.set('limit', String(params.limit));
if (params.offset) q.set('offset', String(params.offset));
const resp = await fetch('/api/entities?' + q);
return resp.json();
},
async createEntity(data) {
const resp = await fetch('/api/entities', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return resp.json();
},
async getEntity(id) {
const resp = await fetch('/api/entities/' + id);
return resp.json();
},
async updateEntity(id, data) {
const resp = await fetch('/api/entities/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return resp.json();
},
async deleteEntity(id) {
return fetch('/api/entities/' + id, { method: 'DELETE' });
},
async promoteEntity(id, cardType, cardData) {
const resp = await fetch('/api/entities/' + id + '/promote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ card_type: cardType, card_data: cardData }),
});
return resp.json();
},
async demoteEntity(id) {
const resp = await fetch('/api/entities/' + id + '/demote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
return resp.json();
},
async useEntity(id) {
const resp = await fetch('/api/entities/' + id + '/use', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
return resp.json();
},
async absorbEntity(targetId, sourceId) {
const resp = await fetch('/api/entities/' + targetId + '/absorb', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_id: sourceId }),
});
return resp.json();
},
async listTags() {
const resp = await fetch('/api/tags');
return resp.json();
},
};
// ========== Grammar parser (mirrors Go parser) ==========
const VALID_CARDS = { card: 'snippet', c: 'snippet', snippet: 'snippet', template: 'template', checklist: 'checklist', decision: 'decision', link: 'link' };
function parseInput(input) {
input = input.trim();
if (!input) return null;
let glyph = 'note';
let remaining = input;
const sp = remaining.indexOf(' ');
if (sp >= 0) {
const first = remaining.slice(0, sp);
if (first === '-' || first === '▸') { glyph = 'todo'; remaining = remaining.slice(sp + 1).trim(); }
else if (first === '*' || first === '◇') { glyph = 'event'; remaining = remaining.slice(sp + 1).trim(); }
} else {
if (remaining === '-' || remaining === '▸') { glyph = 'todo'; remaining = ''; }
else if (remaining === '*' || remaining === '◇') { glyph = 'event'; remaining = ''; }
}
let titleRaw = null, descRaw = null, hasTitle = false;
const lines = remaining.split('\n');
const firstLine = (lines[0] || '').trim();
if (firstLine.startsWith('|')) {
hasTitle = true;
const titleContent = firstLine.slice(1);
const descIdx = titleContent.indexOf(' // ');
if (descIdx >= 0) {
titleRaw = titleContent.slice(0, descIdx).trim();
descRaw = titleContent.slice(descIdx + 4).trim();
} else {
titleRaw = titleContent.trim();
}
remaining = lines.slice(1).join('\n');
} else {
let descParts = [], startBody = 0;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (trimmed.startsWith('// ') || trimmed === '//') {
descParts.push(trimmed.slice(2).trim());
startBody = i + 1;
} else { break; }
}
if (descParts.length) {
descRaw = descParts.join(' ');
remaining = lines.slice(startBody).join('\n');
} else if (!firstLine.includes('://')) {
const dIdx = firstLine.indexOf(' // ');
if (dIdx >= 0) {
descRaw = firstLine.slice(dIdx + 4).trim();
remaining = firstLine.slice(0, dIdx).trim();
if (lines.length > 1) remaining += '\n' + lines.slice(1).join('\n');
}
}
}
let timeAnchor = null, cardSuffix = null;
const tags = [], seenTags = {};
function extract(text) {
const tokens = text.split(/\s+/).filter(Boolean);
const parts = [];
for (const tok of tokens) {
if (tok.startsWith('@') && tok.length > 1) {
timeAnchor = tok.slice(1);
} else if (tok.startsWith('#') && tok.length > 1) {
const tag = tok.slice(1);
if (!seenTags[tag]) { tags.push(tag); seenTags[tag] = true; }
} else if (tok.startsWith('^') && tok.length > 1) {
const suffix = tok.slice(1);
if (VALID_CARDS[suffix]) cardSuffix = VALID_CARDS[suffix];
} else {
parts.push(tok);
}
}
return parts.join(' ');
}
let title = null, description = null;
if (hasTitle) {
const clean = extract(titleRaw || '');
if (clean) title = clean;
}
if (descRaw) {
const clean = extract(descRaw);
if (clean) description = clean;
}
const body = extract(remaining);
if (!body && !title) return null;
return { body, glyph, title, description, timeAnchor, tags, cardSuffix };
}
function detectCardType(body) {
if (/\$\{.+\}/.test(body)) return 'template';
if (/\bchose:|why:/.test(body)) return 'decision';
if (/\[ \]|\d+\./.test(body)) return 'checklist';
if (/https?:\/\//.test(body)) return 'link';
return null;
}
// ========== Rendering ==========
function displayGlyph(entity) {
return entity.card_type ? GLYPHS[entity.card_type] : GLYPHS[entity.glyph];
}
function glyphClass(entity) {
return entity.card_type ? GLYPH_CLASSES[entity.card_type] : GLYPH_CLASSES[entity.glyph];
}
function formatDate(dateStr) {
const d = new Date(dateStr);
const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
return months[d.getMonth()] + ' ' + d.getDate();
}
function renderTagRail() {
const rail = $('#tag-rail');
const allItem = `
all
`;
rail.innerHTML = allItem + state.tags.map(t =>
`
${t.tag}
${t.count}
`
).join('');
rail.querySelectorAll('.tag-item').forEach(el => {
el.addEventListener('click', () => {
state.activeTag = el.dataset.tag || null;
loadEntities();
renderTagRail();
});
});
}
function groupByDate(entities) {
const groups = [];
let current = null;
for (const e of entities) {
const label = formatDate(e.created_at);
if (!current || current.label !== label) {
if (current) groups.push(current);
current = { label, entities: [] };
}
current.entities.push(e);
}
if (current) groups.push(current);
return groups;
}
function renderEntityList() {
const list = $('#entity-list');
if (state.entities.length === 0) {
list.innerHTML = 'no entities yet
';
return;
}
let html = '';
if (state.view === 'stream') {
const groups = groupByDate(state.entities);
let idx = 0;
for (const g of groups) {
html += ``;
for (const e of g.entities) {
html += renderEntityItem(e, idx);
idx++;
}
}
} else {
state.entities.forEach((e, idx) => {
html += renderEntityItem(e, idx);
});
}
if (state.hasMore) {
html += 'load more
';
}
list.innerHTML = html;
list.querySelectorAll('.entity-item').forEach(el => {
el.addEventListener('click', () => {
selectEntity(parseInt(el.dataset.index));
});
});
const loadMoreBtn = list.querySelector('.load-more-btn');
if (loadMoreBtn) loadMoreBtn.addEventListener('click', loadMore);
}
function renderEntityItem(e, idx) {
const glyph = displayGlyph(e);
const gc = glyphClass(e);
const selected = idx === state.selectedIndex ? 'selected' : '';
const tags = (e.tags || []).map(t => `${t} `).join('');
const time = e.time_anchor ? `@${e.time_anchor} ` : '';
const useBadge = e.use_count > 0 ? `${e.use_count}× ` : '';
let label;
if (e.title) {
const preview = e.body ? `${escHtml(e.body)} ` : '';
label = `${escHtml(e.title)} ${preview}`;
} else {
label = `${escHtml(e.body)} `;
}
return `
${glyph}
${label}
${time}
${tags}
${useBadge}
`;
}
function renderDetailPane() {
const pane = $('#detail-pane');
const e = state.entities[state.selectedIndex];
if (!e) {
pane.innerHTML = 'select an entity
';
pane.classList.remove('visible');
return;
}
pane.classList.add('visible');
const glyph = displayGlyph(e);
const gc = glyphClass(e);
const tags = (e.tags || []).map(t => `#${t} `).join('');
const shortId = e.id.slice(0, 12);
let cardContent = '';
let actions = '';
if (e.card_type) {
cardContent = renderCardContent(e);
actions += `copy `;
actions += `demote `;
} else {
actions += `promote `;
actions += `absorb `;
}
actions += `delete `;
const descHtml = e.description ? `${escHtml(e.description)}
` : '';
const titleHtml = e.title ? `${escHtml(e.title)} ` : '';
pane.innerHTML = `
${descHtml}
${titleHtml}
${escHtml(e.body)}
${tags ? `${tags}
` : ''}
${cardContent}
${actions}
`;
const titleEl = pane.querySelector('.detail-title');
if (titleEl) titleEl.addEventListener('dblclick', () => startEditField('title'));
const descEl = pane.querySelector('.detail-desc');
if (descEl) descEl.addEventListener('dblclick', () => startEditField('description'));
const bodyEl = pane.querySelector('.detail-body');
if (bodyEl) bodyEl.addEventListener('dblclick', startEditBody);
}
function renderCardContent(e) {
if (!e.card_data) return '';
let data;
try { data = JSON.parse(e.card_data); } catch { return ''; }
switch (e.card_type) {
case 'template':
if (!data.slots || !data.slots.length) return '';
return ``;
case 'checklist':
if (!data.steps || !data.steps.length) return '';
return `
${data.steps.map((s, i) => `
${escHtml(s.text)}
`).join('')}
`;
case 'decision':
return `
chose
${escHtml(data.chose || '—')}
why
${escHtml(data.why || '—')}
${data.rejected && data.rejected.length ? `
rejected
${data.rejected.map(escHtml).join(', ') || '—'}
` : ''}
`;
case 'link':
if (data.url && isSafeUrl(data.url)) {
return `
open link
`;
}
return '';
default:
return '';
}
}
// ========== Inline edit ==========
function startEditBody() {
const e = state.entities[state.selectedIndex];
if (!e) return;
const el = $(`.detail-body[data-id="${e.id}"]`);
if (!el || el.tagName === 'TEXTAREA') return;
const ta = document.createElement('textarea');
ta.className = 'detail-body-edit';
ta.value = e.body;
el.replaceWith(ta);
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
async function save() {
const newBody = ta.value.trim();
if (newBody && newBody !== e.body) {
await api.updateEntity(e.id, { body: newBody });
await loadEntities();
const idx = state.entities.findIndex(x => x.id === e.id);
if (idx >= 0) selectEntity(idx);
} else {
renderDetailPane();
}
}
ta.addEventListener('blur', save);
ta.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' && ev.ctrlKey) { ev.preventDefault(); ta.removeEventListener('blur', save); save(); }
if (ev.key === 'Escape') { ev.preventDefault(); ta.removeEventListener('blur', save); renderDetailPane(); }
});
}
function startEditField(field) {
const e = state.entities[state.selectedIndex];
if (!e) return;
const cls = field === 'title' ? '.detail-title' : '.detail-desc';
const el = $(`${cls}[data-id="${e.id}"]`);
if (!el || el.tagName === 'INPUT') return;
const input = document.createElement('input');
input.type = 'text';
input.className = 'detail-field-edit';
input.value = e[field] || '';
input.placeholder = field;
el.replaceWith(input);
input.focus();
async function save() {
const val = input.value.trim();
if (val !== (e[field] || '')) {
await api.updateEntity(e.id, { [field]: val || null });
await loadEntities();
const idx = state.entities.findIndex(x => x.id === e.id);
if (idx >= 0) selectEntity(idx);
} else {
renderDetailPane();
}
}
input.addEventListener('blur', save);
input.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') { ev.preventDefault(); input.removeEventListener('blur', save); save(); }
if (ev.key === 'Escape') { ev.preventDefault(); input.removeEventListener('blur', save); renderDetailPane(); }
});
}
// ========== Actions ==========
function selectEntity(idx) {
state.selectedIndex = idx;
renderEntityList();
renderDetailPane();
}
function buildListParams(offset) {
const params = { limit: PAGE_SIZE, offset: offset || 0 };
if (state.activeTag) params.tag = state.activeTag;
if (state.view === 'cards') {
params.cards_only = true;
params.sort = 'use_count';
params.order = 'desc';
} else {
params.sort = 'created';
params.order = 'desc';
}
if (state.activeMonth) {
const [y, m] = state.activeMonth.split('-').map(Number);
params.from = state.activeMonth + '-01';
const last = new Date(y, m, 0).getDate();
params.to = state.activeMonth + '-' + String(last).padStart(2, '0');
}
return params;
}
async function loadEntities() {
const params = buildListParams(0);
const results = await api.listEntities(params);
state.entities = results;
state.hasMore = results.length === PAGE_SIZE;
state.selectedIndex = -1;
renderEntityList();
renderDetailPane();
}
async function loadMore() {
const params = buildListParams(state.entities.length);
const results = await api.listEntities(params);
state.entities = state.entities.concat(results);
state.hasMore = results.length === PAGE_SIZE;
renderEntityList();
}
function renderMonthNav() {
const nav = $('#month-nav');
if (state.view !== 'stream') { nav.innerHTML = ''; return; }
const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
const label = state.activeMonth
? (() => { const [y, m] = state.activeMonth.split('-'); return MONTHS[parseInt(m) - 1] + ' ' + y; })()
: 'all time';
nav.innerHTML = `
◂
${label}
▸
${state.activeMonth ? 'clear ' : ''}
`;
$('#month-prev').addEventListener('click', () => shiftMonth(-1));
$('#month-next').addEventListener('click', () => shiftMonth(1));
const clearBtn = nav.querySelector('.month-nav-clear');
if (clearBtn) clearBtn.addEventListener('click', () => { state.activeMonth = null; loadEntities(); renderMonthNav(); });
}
function shiftMonth(dir) {
if (!state.activeMonth) {
const now = new Date();
state.activeMonth = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0');
} else {
const [y, m] = state.activeMonth.split('-').map(Number);
const d = new Date(y, m - 1 + dir, 1);
state.activeMonth = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0');
}
loadEntities();
renderMonthNav();
}
async function loadTags() {
state.tags = await api.listTags();
renderTagRail();
}
function switchView(view) {
state.view = view;
state.activeMonth = null;
$$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view));
window.location.hash = view === 'cards' ? '/cards' : '/';
loadEntities();
renderMonthNav();
}
// ========== Public API (for inline handlers) ==========
window.nibApp = {
async copyEntity(id) {
const e = state.entities.find(x => x.id === id);
if (!e) return;
try {
await navigator.clipboard.writeText(e.body);
await api.useEntity(id);
await loadEntities();
} catch (err) {
console.error('clipboard:', err);
}
},
showPromote(id) {
const e = state.entities.find(x => x.id === id);
if (!e || e.card_type) return;
const modal = $('#promote-modal');
modal.classList.remove('hidden');
modal.classList.add('visible');
modal.dataset.entityId = id;
const suggested = detectCardType(e.body);
$$('.type-btn').forEach(btn => {
btn.classList.toggle('suggested', btn.dataset.type === suggested);
});
},
async demoteEntity(id) {
await api.demoteEntity(id);
await loadEntities();
await loadTags();
},
async deleteEntity(id) {
await api.deleteEntity(id);
await loadEntities();
await loadTags();
},
async resolveTemplate(id) {
const e = state.entities.find(x => x.id === id);
if (!e) return;
let resolved = e.body;
$$('.slot-input').forEach(input => {
const name = input.dataset.slot;
const val = input.value || input.placeholder;
resolved = resolved.replace(new RegExp('\\$\\{' + name + '\\}', 'g'), val);
});
try {
await navigator.clipboard.writeText(resolved);
await api.useEntity(id);
await loadEntities();
} catch (err) {
console.error('clipboard:', err);
}
},
showAbsorb(targetId) {
const target = state.entities.find(x => x.id === targetId);
if (!target) return;
if (target.card_type) return;
const modal = $('#absorb-modal');
modal.dataset.targetId = targetId;
const list = $('#absorb-source-list');
const sources = state.entities.filter(x => x.id !== targetId);
if (!sources.length) { list.innerHTML = 'no other entities
'; }
else {
list.innerHTML = sources.map(e => {
const g = displayGlyph(e);
const gc = glyphClass(e);
const label = e.title ? escHtml(e.title) : escHtml(e.body);
return `
${g}
${label}
`;
}).join('');
}
list.querySelectorAll('.absorb-source-item').forEach(el => {
el.addEventListener('click', async () => {
modal.classList.add('hidden');
modal.classList.remove('visible');
await api.absorbEntity(targetId, el.dataset.id);
await loadEntities();
await loadTags();
const idx = state.entities.findIndex(x => x.id === targetId);
if (idx >= 0) selectEntity(idx);
});
});
modal.classList.remove('hidden');
modal.classList.add('visible');
},
async toggleStep(id, stepIdx) {
const e = state.entities.find(x => x.id === id);
if (!e || !e.card_data) return;
const data = JSON.parse(e.card_data);
data.steps[stepIdx].done = !data.steps[stepIdx].done;
await api.updateEntity(id, { card_data: JSON.stringify(data) });
await loadEntities();
selectEntity(state.entities.findIndex(x => x.id === id));
},
};
// ========== Capture bar ==========
$('#capture-bar').addEventListener('submit', async (ev) => {
ev.preventDefault();
const input = $('#capture-input');
const val = input.value.trim();
if (!val) return;
const parsed = parseInput(val);
if (!parsed) return;
const data = {
body: parsed.body,
glyph: parsed.glyph,
tags: parsed.tags,
};
if (parsed.title) data.title = parsed.title;
if (parsed.description) data.description = parsed.description;
if (parsed.timeAnchor) data.time_anchor = parsed.timeAnchor;
if (parsed.cardSuffix) data.card_type = parsed.cardSuffix;
await api.createEntity(data);
input.value = '';
await loadEntities();
await loadTags();
});
// ========== Promote modal ==========
$$('.type-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const modal = $('#promote-modal');
const id = modal.dataset.entityId;
modal.classList.add('hidden');
modal.classList.remove('visible');
await api.promoteEntity(id, btn.dataset.type);
await loadEntities();
await loadTags();
});
});
$$('.modal-backdrop').forEach(el => el.addEventListener('click', closeModal));
$$('.modal-close').forEach(el => el.addEventListener('click', closeModal));
function closeModal() {
$$('.modal.visible').forEach(m => {
m.classList.add('hidden');
m.classList.remove('visible');
});
}
// ========== Keyboard shortcuts ==========
let lastDTime = 0;
const captureInput = $('#capture-input');
document.addEventListener('keydown', (ev) => {
if (document.activeElement === captureInput ||
document.activeElement.classList.contains('detail-body-edit')) {
if (ev.key === 'Escape') document.activeElement.blur();
return;
}
if ($('#promote-modal').classList.contains('visible') ||
$('#absorb-modal').classList.contains('visible')) {
if (ev.key === 'Escape') closeModal();
return;
}
switch (ev.key) {
case 'j':
ev.preventDefault();
selectEntity(Math.min(state.selectedIndex + 1, state.entities.length - 1));
scrollSelectedIntoView();
break;
case 'k':
ev.preventDefault();
selectEntity(Math.max(state.selectedIndex - 1, 0));
scrollSelectedIntoView();
break;
case 'n':
ev.preventDefault();
captureInput.focus();
break;
case 'p': {
const e = state.entities[state.selectedIndex];
if (e && !e.card_type) nibApp.showPromote(e.id);
break;
}
case 'Enter': {
const e = state.entities[state.selectedIndex];
if (e) nibApp.copyEntity(e.id);
break;
}
case 'd': {
const now = Date.now();
if (now - lastDTime < 400) {
const e = state.entities[state.selectedIndex];
if (e) nibApp.deleteEntity(e.id);
lastDTime = 0;
} else {
lastDTime = now;
}
break;
}
case 'e': {
startEditBody();
break;
}
case 'a': {
const e = state.entities[state.selectedIndex];
if (e && !e.card_type) nibApp.showAbsorb(e.id);
break;
}
case '1': switchView('stream'); break;
case '2': switchView('cards'); break;
}
});
function scrollSelectedIntoView() {
const el = $(`.entity-item[data-index="${state.selectedIndex}"]`);
if (el) el.scrollIntoView({ block: 'nearest' });
}
// ========== View nav buttons ==========
$$('.nav-btn').forEach(btn => {
btn.addEventListener('click', () => switchView(btn.dataset.view));
});
// ========== Hash routing ==========
function handleHash() {
const hash = window.location.hash;
if (hash === '#/cards') {
state.view = 'cards';
} else {
state.view = 'stream';
}
$$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === state.view));
loadEntities();
}
window.addEventListener('hashchange', handleHash);
// ========== Utils ==========
function escHtml(s) {
if (!s) return '';
return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
function escAttr(s) {
return escHtml(s).replace(/'/g, ''');
}
function isSafeUrl(url) {
return /^https?:\/\//i.test(url);
}
// ========== Theme ==========
const themeToggle = $('#theme-toggle');
let nibTheme = localStorage.getItem('nib:theme') || 'dark';
document.documentElement.setAttribute('data-theme', nibTheme);
themeToggle.textContent = nibTheme === 'paper' ? '◐' : '◑';
themeToggle.addEventListener('click', () => {
nibTheme = nibTheme === 'dark' ? 'paper' : 'dark';
document.documentElement.setAttribute('data-theme', nibTheme);
localStorage.setItem('nib:theme', nibTheme);
themeToggle.textContent = nibTheme === 'paper' ? '◐' : '◑';
});
// ========== Init ==========
async function init() {
await Promise.all([loadEntities(), loadTags()]);
handleHash();
renderMonthNav();
}
init();
})();