fix: address code review findings across backend and frontend
CI / test (pull_request) Successful in 2m13s
CI / test (pull_request) Successful in 2m13s
Fix goroutine-unsafe ULID entropy by wrapping in LockedMonotonicReader. Move PRAGMA foreign_keys outside transaction in v3 migration where SQLite was silently ignoring it. Escape LIKE wildcards in link resolution to prevent false matches. Add non-localhost binding warning, log writeJSON encoder errors, add ?permanent=true for explicit hard delete, preserve title/description during absorb, use millisecond backup timestamps, add path.Clean to spaHandler. Frontend gains checkedJSON() for resp.ok validation, consistent stopPropagation, and shared renderCardSections() to eliminate duplicate rendering.
This commit is contained in:
+83
-112
@@ -44,6 +44,15 @@
|
||||
|
||||
// ========== API ==========
|
||||
|
||||
async function checkedJSON(resp) {
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
const msg = body.message || body.error || `HTTP ${resp.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
const api = {
|
||||
async listEntities(params = {}) {
|
||||
const q = new URLSearchParams();
|
||||
@@ -57,7 +66,7 @@
|
||||
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();
|
||||
return checkedJSON(resp);
|
||||
},
|
||||
async createEntity(data) {
|
||||
const resp = await fetch('/api/entities', {
|
||||
@@ -65,11 +74,11 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return resp.json();
|
||||
return checkedJSON(resp);
|
||||
},
|
||||
async getEntity(id) {
|
||||
const resp = await fetch('/api/entities/' + id);
|
||||
return resp.json();
|
||||
return checkedJSON(resp);
|
||||
},
|
||||
async updateEntity(id, data) {
|
||||
const resp = await fetch('/api/entities/' + id, {
|
||||
@@ -77,10 +86,11 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return resp.json();
|
||||
return checkedJSON(resp);
|
||||
},
|
||||
async deleteEntity(id) {
|
||||
return fetch('/api/entities/' + id, { method: 'DELETE' });
|
||||
const resp = await fetch('/api/entities/' + id, { method: 'DELETE' });
|
||||
return checkedJSON(resp);
|
||||
},
|
||||
async promoteEntity(id, cardType, cardData) {
|
||||
const resp = await fetch('/api/entities/' + id + '/promote', {
|
||||
@@ -88,7 +98,7 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ card_type: cardType, card_data: cardData }),
|
||||
});
|
||||
return resp.json();
|
||||
return checkedJSON(resp);
|
||||
},
|
||||
async demoteEntity(id) {
|
||||
const resp = await fetch('/api/entities/' + id + '/demote', {
|
||||
@@ -96,7 +106,7 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
return resp.json();
|
||||
return checkedJSON(resp);
|
||||
},
|
||||
async useEntity(id) {
|
||||
const resp = await fetch('/api/entities/' + id + '/use', {
|
||||
@@ -104,7 +114,7 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
return resp.json();
|
||||
return checkedJSON(resp);
|
||||
},
|
||||
async absorbEntity(targetId, sourceId) {
|
||||
const resp = await fetch('/api/entities/' + targetId + '/absorb', {
|
||||
@@ -112,13 +122,13 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source_id: sourceId }),
|
||||
});
|
||||
return resp.json();
|
||||
return checkedJSON(resp);
|
||||
},
|
||||
async listTags(params = {}) {
|
||||
const q = new URLSearchParams();
|
||||
if (params.cards_only) q.set('cards_only', 'true');
|
||||
const resp = await fetch('/api/tags?' + q);
|
||||
return resp.json();
|
||||
return checkedJSON(resp);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -677,6 +687,52 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderCardSections(e, bodyClass) {
|
||||
const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {};
|
||||
const hasDecision = data.chose != null;
|
||||
const hasSteps = data.steps && data.steps.length;
|
||||
const hasLink = !!data.url;
|
||||
const hasFill = /\$\{[^}]+\}/.test(e.body || '');
|
||||
|
||||
let sections = '';
|
||||
if (hasDecision) {
|
||||
const rejected = (data.rejected || []).map(r => `<span class="peek-dec-rej">${escHtml(r)}</span>`).join('');
|
||||
sections += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">decision<span class="peek-sec-status">${data.status || 'decided'}</span></div>
|
||||
<div class="peek-sec-inner peek-decision">
|
||||
<div class="peek-dec-choice">${escHtml(data.chose)}</div>
|
||||
<div class="peek-dec-why"><span class="peek-dec-key">why</span>${escHtml(data.why || '')}</div>
|
||||
${rejected ? `<div><span class="peek-dec-key">considered</span><div class="peek-dec-rejected">${rejected}</div></div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
if (hasLink && !hasDecision) {
|
||||
sections += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">link</div>
|
||||
<div class="peek-sec-inner"><div class="peek-link-url">↗ ${escHtml(data.url)}</div></div>
|
||||
</div>`;
|
||||
}
|
||||
if (hasSteps) {
|
||||
const steps = data.steps.map(s => `<div class="peek-step"><span class="peek-step-mark" style="color:var(--dim)">○</span><span class="peek-step-text">${escHtml(s.text || s)}</span></div>`).join('');
|
||||
sections += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">steps · ${data.steps.length}<button class="peek-sec-run" onclick="event.stopPropagation();nibApp.enterMode('run')">▶ run</button></div>
|
||||
<div class="peek-sec-inner"><div class="peek-steps">${steps}</div></div>
|
||||
</div>`;
|
||||
}
|
||||
if (!hasDecision && e.body) {
|
||||
const lang = data.lang || '';
|
||||
const isCode = lang || e.card_type === 'snippet';
|
||||
const bodyHtml = isCode
|
||||
? `<div class="peek-code"><pre><code>${escHtml(e.body)}</code></pre></div>`
|
||||
: `<div class="${bodyClass} md">${renderMd(e.body)}</div>`;
|
||||
sections += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">content${lang ? `<span class="peek-sec-lang">${lang}</span>` : ''}${hasFill ? `<button class="peek-sec-run" onclick="event.stopPropagation();nibApp.enterMode('fill')">⤓ fill</button>` : ''}</div>
|
||||
<div class="peek-sec-inner">${bodyHtml}</div>
|
||||
</div>`;
|
||||
}
|
||||
return { sections, data, hasDecision, hasSteps, hasLink, hasFill };
|
||||
}
|
||||
|
||||
function renderInlineDetail(e) {
|
||||
const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join('');
|
||||
let actions = '';
|
||||
@@ -694,51 +750,10 @@
|
||||
|
||||
let content = '';
|
||||
if (e.card_type) {
|
||||
const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {};
|
||||
const hasDecision = data.chose != null;
|
||||
const hasSteps = data.steps && data.steps.length;
|
||||
const hasLink = !!data.url;
|
||||
const hasFill = /\$\{[^}]+\}/.test(e.body || '');
|
||||
|
||||
if (hasDecision) {
|
||||
const rejected = (data.rejected || []).map(r => `<span class="peek-dec-rej">${escHtml(r)}</span>`).join('');
|
||||
content += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">decision<span class="peek-sec-status">${data.status || 'decided'}</span></div>
|
||||
<div class="peek-sec-inner peek-decision">
|
||||
<div class="peek-dec-choice">${escHtml(data.chose)}</div>
|
||||
<div class="peek-dec-why"><span class="peek-dec-key">why</span>${escHtml(data.why || '')}</div>
|
||||
${rejected ? `<div><span class="peek-dec-key">considered</span><div class="peek-dec-rejected">${rejected}</div></div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
if (hasLink && !hasDecision) {
|
||||
content += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">link</div>
|
||||
<div class="peek-sec-inner"><div class="peek-link-url">↗ ${escHtml(data.url)}</div></div>
|
||||
</div>`;
|
||||
}
|
||||
if (hasSteps) {
|
||||
const steps = data.steps.map(s => `<div class="peek-step"><span class="peek-step-mark" style="color:var(--dim)">○</span><span class="peek-step-text">${escHtml(s.text || s)}</span></div>`).join('');
|
||||
content += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">steps · ${data.steps.length}<button class="peek-sec-run" onclick="event.stopPropagation();nibApp.enterMode('run')">▶ run</button></div>
|
||||
<div class="peek-sec-inner"><div class="peek-steps">${steps}</div></div>
|
||||
</div>`;
|
||||
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('run')">run</button>`;
|
||||
}
|
||||
if (hasFill) {
|
||||
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('fill')">fill</button>`;
|
||||
}
|
||||
if (!hasDecision && e.body) {
|
||||
const lang = data.lang || '';
|
||||
const isCode = lang || e.card_type === 'snippet';
|
||||
const bodyHtml = isCode
|
||||
? `<div class="peek-code"><pre><code>${escHtml(e.body)}</code></pre></div>`
|
||||
: `<div class="exp-body md">${renderMd(e.body)}</div>`;
|
||||
content += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">content${lang ? `<span class="peek-sec-lang">${lang}</span>` : ''}</div>
|
||||
<div class="peek-sec-inner">${bodyHtml}</div>
|
||||
</div>`;
|
||||
}
|
||||
const cs = renderCardSections(e, 'exp-body');
|
||||
content = cs.sections;
|
||||
if (cs.hasSteps) actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('run')">run</button>`;
|
||||
if (cs.hasFill) actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('fill')">fill</button>`;
|
||||
} else {
|
||||
content = `<div class="exp-body md">${renderMd(e.body || '')}</div>`;
|
||||
}
|
||||
@@ -861,15 +876,15 @@
|
||||
const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join('');
|
||||
|
||||
let actions = '';
|
||||
actions += `<button class="action-btn" onclick="nibApp.enterMode('edit')">edit <kbd>e</kbd></button>`;
|
||||
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('edit')">edit <kbd>e</kbd></button>`;
|
||||
if (!e.card_type) {
|
||||
actions += `<button class="action-btn" onclick="nibApp.showAbsorb('${e.id}')">absorb <kbd>a</kbd></button>`;
|
||||
actions += `<button class="action-btn primary" onclick="nibApp.showPromote('${e.id}')">promote →</button>`;
|
||||
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.showAbsorb('${e.id}')">absorb <kbd>a</kbd></button>`;
|
||||
actions += `<button class="action-btn primary" onclick="event.stopPropagation();nibApp.showPromote('${e.id}')">promote →</button>`;
|
||||
}
|
||||
if (e.card_type) {
|
||||
actions += `<button class="action-btn danger" onclick="nibApp.demoteEntity('${e.id}')">demote</button>`;
|
||||
actions += `<button class="action-btn danger" onclick="event.stopPropagation();nibApp.demoteEntity('${e.id}')">demote</button>`;
|
||||
} else {
|
||||
actions += `<button class="action-btn danger" onclick="nibApp.deleteEntity('${e.id}')">delete</button>`;
|
||||
actions += `<button class="action-btn danger" onclick="event.stopPropagation();nibApp.deleteEntity('${e.id}')">delete</button>`;
|
||||
}
|
||||
|
||||
return `<div class="peek-scroll">
|
||||
@@ -899,61 +914,17 @@
|
||||
const glyph = GLYPHS[e.card_type] || '◆';
|
||||
const gc = GLYPH_CLASSES[e.card_type] || 'glyph-snippet';
|
||||
const affs = detectAffordances(e);
|
||||
const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {};
|
||||
const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join('');
|
||||
const affHtml = affs.map(a => `<span class="aff ${AFF_CLASSES[a]}">${AFF_LABELS[a]}</span>`).join('');
|
||||
const hasSteps = data.steps && data.steps.length;
|
||||
const hasDecision = data.chose != null;
|
||||
const hasFill = /\$\{[^}]+\}/.test(e.body || '');
|
||||
const hasLink = !!data.url;
|
||||
|
||||
let sections = '';
|
||||
const cs = renderCardSections(e, 'peek-body');
|
||||
|
||||
if (hasDecision) {
|
||||
const rejected = (data.rejected || []).map(r => `<span class="peek-dec-rej">${escHtml(r)}</span>`).join('');
|
||||
sections += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">decision<span class="peek-sec-status">${data.status || 'decided'}</span></div>
|
||||
<div class="peek-sec-inner peek-decision">
|
||||
<div class="peek-dec-choice">${escHtml(data.chose)}</div>
|
||||
<div class="peek-dec-why"><span class="peek-dec-key">why</span>${escHtml(data.why || '')}</div>
|
||||
${rejected ? `<div><span class="peek-dec-key">considered</span><div class="peek-dec-rejected">${rejected}</div></div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (hasLink && !hasDecision) {
|
||||
sections += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">link</div>
|
||||
<div class="peek-sec-inner"><div class="peek-link-url">↗ ${escHtml(data.url)}</div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (hasSteps) {
|
||||
const steps = data.steps.map((s, i) => `<div class="peek-step"><span class="peek-step-mark" style="color:var(--dim)">○</span><span class="peek-step-text">${escHtml(s.text || s)}</span></div>`).join('');
|
||||
sections += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">steps · ${data.steps.length}<button class="peek-sec-run" onclick="nibApp.enterMode('run')">▶ run</button></div>
|
||||
<div class="peek-sec-inner"><div class="peek-steps">${steps}</div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (!hasDecision && e.body) {
|
||||
const lang = data.lang || '';
|
||||
const isCode = lang || e.card_type === 'snippet';
|
||||
const bodyHtml = isCode
|
||||
? `<div class="peek-code"><pre><code>${escHtml(e.body)}</code></pre></div>`
|
||||
: `<div class="peek-body md">${renderMd(e.body)}</div>`;
|
||||
sections += `<div class="peek-sec">
|
||||
<div class="peek-sec-lbl">content${lang ? `<span class="peek-sec-lang">${lang}</span>` : ''}${hasFill ? `<button class="peek-sec-run" onclick="nibApp.enterMode('fill')">⤓ fill</button>` : ''}</div>
|
||||
<div class="peek-sec-inner">${bodyHtml}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
let actions = `<button class="action-btn primary" onclick="nibApp.copyEntity('${e.id}')">copy <kbd>⏎</kbd></button>`;
|
||||
if (hasFill) actions += `<button class="action-btn" onclick="nibApp.enterMode('fill')">fill <kbd>f</kbd></button>`;
|
||||
if (hasSteps) actions += `<button class="action-btn" onclick="nibApp.enterMode('run')">run <kbd>r</kbd></button>`;
|
||||
actions += `<button class="action-btn" onclick="nibApp.enterMode('edit')">edit <kbd>e</kbd></button>`;
|
||||
actions += `<button class="action-btn" onclick="nibApp.togglePin('${e.id}')">${e.pinned ? 'unpin' : 'pin'} <kbd>p</kbd></button>`;
|
||||
actions += `<button class="action-btn danger" onclick="nibApp.demoteEntity('${e.id}')">demote</button>`;
|
||||
let actions = `<button class="action-btn primary" onclick="event.stopPropagation();nibApp.copyEntity('${e.id}')">copy <kbd>⏎</kbd></button>`;
|
||||
if (cs.hasFill) actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('fill')">fill <kbd>f</kbd></button>`;
|
||||
if (cs.hasSteps) actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('run')">run <kbd>r</kbd></button>`;
|
||||
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('edit')">edit <kbd>e</kbd></button>`;
|
||||
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.togglePin('${e.id}')">${e.pinned ? 'unpin' : 'pin'} <kbd>p</kbd></button>`;
|
||||
actions += `<button class="action-btn danger" onclick="event.stopPropagation();nibApp.demoteEntity('${e.id}')">demote</button>`;
|
||||
|
||||
return `<div class="peek-scroll">
|
||||
<div class="peek-card">
|
||||
@@ -969,7 +940,7 @@
|
||||
${e.description ? `<div class="peek-desc" style="padding:0 16px 10px">${escHtml(e.description)}</div>` : ''}
|
||||
<div class="peek-meta" style="padding:0 16px 12px">${affHtml}${tags}${e.pinned ? '<span class="peek-pin">★</span>' : ''}</div>
|
||||
</div>
|
||||
${sections}
|
||||
${cs.sections}
|
||||
</div>
|
||||
<div class="peek-acts">${actions}</div>
|
||||
</div>`;
|
||||
|
||||
Reference in New Issue
Block a user