feat: add title and description fields to capture grammar
Implement | prefix for titles and // separator for descriptions across the full stack: parser, schema, API, CLI, and web frontend. - Parser: line-aware extraction for |title, |title // desc, // leading desc, body // inline desc. URL-safe (skips :// lines). Modifiers (#tag, @time, ^card) extracted from all segments. - Schema: ALTER TABLE migration adds title, description columns - DB: Entity/EntityUpdate structs, all CRUD queries updated - API: title/description on create/update/response, body validation relaxed (title-only entries valid) - CLI: shows title as scan label when present - Web: parseInput mirrors Go parser, list shows title, detail pane renders title + description with double-click inline editing - Tests: 10 new cases (grammar, entity, API) — 71 total, all pass
This commit is contained in:
+133
-24
@@ -115,37 +115,92 @@
|
||||
input = input.trim();
|
||||
if (!input) return null;
|
||||
|
||||
const tokens = input.split(/\s+/);
|
||||
let glyph = 'note';
|
||||
let remaining = input;
|
||||
|
||||
const first = tokens[0];
|
||||
if (first === '-' || first === '▸') { glyph = 'todo'; tokens.shift(); }
|
||||
else if (first === '*' || first === '◇') { glyph = 'event'; tokens.shift(); }
|
||||
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 = ''; }
|
||||
}
|
||||
|
||||
const bodyParts = [];
|
||||
let timeAnchor = null;
|
||||
const tags = [];
|
||||
const seenTags = {};
|
||||
let cardSuffix = null;
|
||||
let titleRaw = null, descRaw = null, hasTitle = false;
|
||||
const lines = remaining.split('\n');
|
||||
const firstLine = (lines[0] || '').trim();
|
||||
|
||||
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];
|
||||
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 {
|
||||
bodyParts.push(tok);
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const body = bodyParts.join(' ');
|
||||
if (!body) return null;
|
||||
let timeAnchor = null, cardSuffix = null;
|
||||
const tags = [], seenTags = {};
|
||||
|
||||
return { body, glyph, timeAnchor, tags, cardSuffix };
|
||||
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) {
|
||||
@@ -258,9 +313,17 @@
|
||||
const time = e.time_anchor ? `<span class="entity-time">@${e.time_anchor}</span>` : '';
|
||||
const useBadge = e.use_count > 0 ? `<span class="use-badge">${e.use_count}×</span>` : '';
|
||||
|
||||
let label;
|
||||
if (e.title) {
|
||||
const preview = e.body ? `<span class="entity-preview">${escHtml(e.body)}</span>` : '';
|
||||
label = `<span class="entity-title">${escHtml(e.title)}</span>${preview}`;
|
||||
} else {
|
||||
label = `<span class="entity-body">${escHtml(e.body)}</span>`;
|
||||
}
|
||||
|
||||
return `<div class="entity-item ${selected}" data-index="${idx}" data-id="${e.id}">
|
||||
<span class="entity-glyph ${gc}">${glyph}</span>
|
||||
<span class="entity-body">${escHtml(e.body)}</span>
|
||||
${label}
|
||||
${time}
|
||||
<span class="entity-tags">${tags}</span>
|
||||
<span class="entity-meta">${useBadge}</span>
|
||||
@@ -296,18 +359,27 @@
|
||||
}
|
||||
actions += `<button class="action-btn danger" onclick="nibApp.deleteEntity('${e.id}')">delete</button>`;
|
||||
|
||||
const descHtml = e.description ? `<div class="detail-desc" data-id="${e.id}">${escHtml(e.description)}</div>` : '';
|
||||
const titleHtml = e.title ? `<h2 class="detail-title" data-id="${e.id}">${escHtml(e.title)}</h2>` : '';
|
||||
|
||||
pane.innerHTML = `
|
||||
<div class="detail-header">
|
||||
<span class="detail-glyph ${gc}">${glyph}</span>
|
||||
<span class="detail-id">${shortId}</span>
|
||||
${e.time_anchor ? `<span class="entity-time">@${e.time_anchor}</span>` : ''}
|
||||
</div>
|
||||
${descHtml}
|
||||
${titleHtml}
|
||||
<div class="detail-body" data-id="${e.id}">${escHtml(e.body)}</div>
|
||||
${tags ? `<div class="detail-tags">${tags}</div>` : ''}
|
||||
${cardContent}
|
||||
<div class="detail-actions">${actions}</div>
|
||||
`;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -395,6 +467,40 @@
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -565,9 +671,10 @@
|
||||
list.innerHTML = sources.map(e => {
|
||||
const g = displayGlyph(e);
|
||||
const gc = glyphClass(e);
|
||||
const label = e.title ? escHtml(e.title) : escHtml(e.body);
|
||||
return `<div class="absorb-source-item" data-id="${e.id}">
|
||||
<span class="entity-glyph ${gc}">${g}</span>
|
||||
<span class="entity-body">${escHtml(e.body)}</span>
|
||||
<span class="entity-body">${label}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -615,6 +722,8 @@
|
||||
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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user