Files
thandClaude Opus 4.6 8e738ed9d4 fix(bookmarks): sync local state on save so columns persist
save() PATCHed the card on the server but left state.cards stale.
The next openOverlay read the old body from the local cache and
the new column was gone. Now save() writes the updated body back
into the matching state.cards entry so close + reopen sees the
latest data without a full _reloadAll round-trip.

Test: "new columns survive close + reopen" — adds a column via
API + local state update, closes, reopens, asserts both column
names are present in the DOM.

Bumps to 1.9.28.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 23:56:42 +02:00

619 lines
26 KiB
JavaScript

// ── Bookmarks Plugin ──
//
// A bookmark list is a card tagged #bookmarks. Its body is a JSON
// blob: { type:"bookmarks", columns:[{name, items:[{url,title,favicon}]}], columnWidth }.
// On the board the card renders as a coloured tile with a link icon
// and no body preview. Clicking opens a column-based overlay; each
// item is a small box with favicon + title. Click → open URL.
//
// Phase 1+2: skeleton, board decoration, overlay (read + write),
// add/delete/rename, clipboard paste + server-side meta fetch.
const hyprcrd = window.hyprcrd;
if (!hyprcrd) throw new Error('[bookmarks] window.hyprcrd missing');
const $ = (id) => document.getElementById(id);
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
const TAG = 'bookmarks';
let activeCardId = null; // card id currently open in the overlay
let data = null; // parsed JSON body of the active card
// ── Helpers ──────────────────────────────────────────────────────
function isBookmarkCard(card) {
return (card.tags || '').split(',').map(s => s.trim()).includes(TAG);
}
function emptyData() {
return { type: 'bookmarks', columns: [{ name: 'General', items: [] }], columnWidth: 220 };
}
function parseBody(body) {
const text = (body || '').trim();
// Full string might be pure JSON (legacy) or JSON + trailing
// "\n\n#bookmarks" tag line that extractTags needs on the server.
try {
const d = JSON.parse(text);
if (d?.type === 'bookmarks' && Array.isArray(d.columns)) return d;
} catch {}
// Strip trailing tag line and retry.
const jsonEnd = text.lastIndexOf('}');
if (jsonEnd >= 0) {
try {
const d = JSON.parse(text.substring(0, jsonEnd + 1));
if (d?.type === 'bookmarks' && Array.isArray(d.columns)) return d;
} catch {}
}
return emptyData();
}
function isUrl(s) {
return /^https?:\/\/.+/i.test((s || '').trim());
}
function isCardLink(s) {
return /^card:\d+$/i.test((s || '').trim());
}
function parseCardId(s) {
const m = /^card:(\d+)$/i.exec((s || '').trim());
return m ? m[1] : null;
}
async function save() {
if (!activeCardId || !data) return;
// The trailing #bookmarks tag is essential: the server's
// extractTags() parses hashtags from the body text to populate
// the `tags` column. Without it, isBookmarkCard (which checks
// tags, not body) wouldn't recognise the card on meta-only
// loads where body is still null.
const body = JSON.stringify(data) + '\n\n#bookmarks';
await hyprcrd.api(`api/cards/${activeCardId}`, {
method: 'PATCH',
body: { body },
});
// Keep the local state in sync so the next openOverlay (which
// reads from state.cards, not the server) sees the latest body.
const local = (window.state?.cards || []).find(c => c.id == activeCardId);
if (local) local.body = body;
}
// ── Board card decoration ────────────────────────────────────────
// MutationObserver on #board: any card whose tags include "bookmarks"
// gets the .bm-card class + a link icon. Click is intercepted to
// open the overlay instead of the detail view.
function decorateBoard() {
const board = $('board');
if (!board) return;
const cards = window.state?.cards || [];
board.querySelectorAll('.board-card[data-id]').forEach(el => {
const card = cards.find(c => String(c.id) === el.dataset.id);
if (!card || !isBookmarkCard(card)) return;
if (el.dataset.bmDone) return;
el.dataset.bmDone = '1';
el.classList.add('bm-card');
// Replace the entire card content with a coloured tile
// showing just a big link icon and the title. The original
// innerHTML (bar + body preview + tags + meta) is noise for
// a bookmark list card.
const color = card.color || '#38bdf8';
el.style.background = `color-mix(in srgb, ${color} 70%, var(--t-surface))`;
el.style.borderColor = color;
const date = (card.updated_at || card.created_at || '').substring(0, 10);
const bucket = card.bucket_name || '';
el.innerHTML = `
<div class="bm-card-inner">
<div class="bm-card-center">
<i class="ph ph-link bm-card-icon"></i>
<span class="bm-card-title">${esc(card.title)}</span>
</div>
<div class="bm-card-meta">
<span><i class="ph ph-clock"></i>${esc(date)}</span>
${bucket ? `<span><i class="ph ph-folder"></i>${esc(bucket)}</span>` : ''}
</div>
</div>
`;
el.addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
openOverlay(card.id);
}, true);
el.addEventListener('dblclick', (e) => {
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
openOverlay(card.id);
}, true);
});
}
// Re-decorate after every board render.
new MutationObserver(decorateBoard).observe(
$('board') || document.body,
{ childList: true, subtree: true }
);
// ── Overlay ──────────────────────────────────────────────────────
const overlay = $('bookmarks-overlay');
const colsEl = $('bookmarks-columns');
const slider = $('bookmarks-col-width');
async function openOverlay(cardId) {
activeCardId = cardId;
let card = (window.state?.cards || []).find(c => c.id == cardId);
if (!card) return;
// Body might still be null if Phase-2 hasn't loaded it yet.
// Fetch it explicitly so we don't clobber the stored JSON
// (including columnWidth) with emptyData() defaults.
if (card.body == null) {
const full = await hyprcrd.api('api/cards?ids=' + cardId);
if (Array.isArray(full) && full[0]) {
const idx = (window.state?.cards || []).findIndex(c => c.id == cardId);
if (idx >= 0) window.state.cards[idx] = full[0];
card = full[0];
}
}
$('bookmarks-title').textContent = card.title || 'Bookmarks';
data = parseBody(card.body);
if (slider) slider.value = data.columnWidth || 220;
applyColumnWidth();
renderColumns();
overlay?.classList.add('open');
}
function closeOverlay() {
overlay?.classList.remove('open');
activeCardId = null;
data = null;
hideCtx();
}
function applyColumnWidth() {
const w = slider ? parseInt(slider.value) : 220;
colsEl?.style.setProperty('--bm-col-w', w + 'px');
if (data) data.columnWidth = w;
}
function renderColumns() {
if (!colsEl || !data) return;
colsEl.innerHTML = '';
data.columns.forEach((col, ci) => {
const div = document.createElement('div');
div.className = 'bm-col';
div.dataset.col = ci;
// Header
const hdr = document.createElement('div');
hdr.className = 'bm-col-header';
hdr.innerHTML = `
<span class="bm-col-name" title="${esc(col.name)}">${esc(col.name)}</span>
<button class="bm-col-add" data-col="${ci}" title="add bookmark"><i class="ph ph-plus"></i></button>
`;
hdr.querySelector('.bm-col-name').addEventListener('click', () => renameColumn(ci));
hdr.querySelector('.bm-col-name').addEventListener('contextmenu', (e) => {
e.preventDefault();
showColCtx(ci, e.clientX, e.clientY);
});
hdr.querySelector('.bm-col-add').addEventListener('click', () => addBookmark(ci));
div.appendChild(hdr);
// Items
const items = document.createElement('div');
items.className = 'bm-col-items';
items.dataset.col = ci;
if (!col.items.length) {
items.innerHTML = '<div class="bm-col-empty">no bookmarks</div>';
} else {
col.items.forEach((it, ii) => {
const el = document.createElement('div');
el.className = 'bm-item';
el.dataset.col = ci;
el.dataset.idx = ii;
el.draggable = true;
const cardId = parseCardId(it.url);
const isCard = !!cardId;
let fav;
if (isCard) {
// Card link: show a card icon instead of a web favicon.
const linked = (window.state?.cards || []).find(c => String(c.id) === cardId);
const linkedColor = linked?.color || 'var(--t-accent)';
fav = `<i class="ph ph-card bm-favicon-placeholder" style="color:${esc(linkedColor)}"></i>`;
} else {
fav = it.favicon
? `<img class="bm-favicon" src="${esc(it.favicon)}" onerror="this.outerHTML='<i class=\\'ph ph-globe bm-favicon-placeholder\\'></i>'">`
: '<i class="ph ph-globe bm-favicon-placeholder"></i>';
}
el.innerHTML = `${fav}<span class="bm-item-title" title="${esc(it.title || it.url)}">${esc(it.title || it.url)}</span>`;
el.addEventListener('click', () => {
if (isCard) {
// Check if the linked card is itself a bookmark list.
const linked = (window.state?.cards || []).find(c => String(c.id) === cardId);
if (linked && isBookmarkCard(linked)) {
openOverlay(linked.id);
} else {
closeOverlay();
window._openDetail?.(parseInt(cardId));
}
return;
}
if (it.url) window.open(it.url, '_blank', 'noopener');
});
el.addEventListener('contextmenu', (e) => {
e.preventDefault();
showItemCtx(ci, ii, e.clientX, e.clientY);
});
// Drag
el.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('application/x-bm', JSON.stringify({ col: ci, idx: ii }));
e.dataTransfer.effectAllowed = 'move';
el.classList.add('dragging');
});
el.addEventListener('dragend', () => el.classList.remove('dragging'));
items.appendChild(el);
});
}
// Drop target — positional: a drop indicator line appears
// between items based on the cursor's vertical position.
// The item is inserted AT that position, not always at the end.
items.addEventListener('dragover', (e) => {
if (!e.dataTransfer.types.includes('application/x-bm')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
// Show a drop indicator between items.
clearDropIndicator(items);
const after = getDropIndex(items, e.clientY);
const children = [...items.querySelectorAll('.bm-item')];
if (after < children.length) {
children[after].classList.add('bm-drop-above');
} else if (children.length) {
children[children.length - 1].classList.add('bm-drop-below');
}
});
items.addEventListener('dragleave', () => clearDropIndicator(items));
items.addEventListener('drop', (e) => {
clearDropIndicator(items);
const raw = e.dataTransfer.getData('application/x-bm');
if (!raw) return;
e.preventDefault();
const src = JSON.parse(raw);
const destCol = ci;
if (!data) return;
const [item] = data.columns[src.col].items.splice(src.idx, 1);
if (!item) return;
// Compute insertion index from cursor position.
const insertAt = getDropIndex(items, e.clientY);
// If source and dest are the same column, the splice above
// shifted indices — getDropIndex already sees the shortened
// list, so insertAt is correct as-is.
data.columns[destCol].items.splice(insertAt, 0, item);
save();
renderColumns();
});
div.appendChild(items);
colsEl.appendChild(div);
});
}
// ── Drag-and-drop helpers ─────────────────────────────────────────
function getDropIndex(container, clientY) {
const children = [...container.querySelectorAll('.bm-item')];
for (let i = 0; i < children.length; i++) {
const r = children[i].getBoundingClientRect();
if (clientY < r.top + r.height / 2) return i;
}
return children.length;
}
function clearDropIndicator(container) {
container.querySelectorAll('.bm-drop-above,.bm-drop-below').forEach(el => {
el.classList.remove('bm-drop-above', 'bm-drop-below');
});
}
// ── Wheel → horizontal scroll ────────────────────────────────────
colsEl?.addEventListener('wheel', (e) => {
if (e.deltaY === 0) return;
if (colsEl.scrollWidth <= colsEl.clientWidth) return;
e.preventDefault();
colsEl.scrollLeft += e.deltaY;
}, { passive: false });
// ── Slider ───────────────────────────────────────────────────────
slider?.addEventListener('input', () => {
applyColumnWidth();
renderColumns();
});
slider?.addEventListener('change', () => save());
// ── Context menus ────────────────────────────────────────────────
let ctxEl = null;
function hideCtx() {
ctxEl?.remove();
ctxEl = null;
}
function showCtx(x, y, buttons) {
hideCtx();
ctxEl = document.createElement('div');
ctxEl.className = 'bm-ctx';
ctxEl.style.left = x + 'px';
ctxEl.style.top = y + 'px';
buttons.forEach(([label, icon, cls, fn]) => {
const b = document.createElement('button');
if (cls) b.className = cls;
b.innerHTML = `<i class="ph ${icon} text-sm"></i>${esc(label)}`;
b.addEventListener('click', () => { hideCtx(); fn(); });
ctxEl.appendChild(b);
});
document.body.appendChild(ctxEl);
// Clamp to viewport
const r = ctxEl.getBoundingClientRect();
if (r.right > window.innerWidth - 8) ctxEl.style.left = Math.max(8, window.innerWidth - r.width - 8) + 'px';
if (r.bottom > window.innerHeight - 8) ctxEl.style.top = Math.max(8, window.innerHeight - r.height - 8) + 'px';
setTimeout(() => {
const off = (e) => { if (!ctxEl?.contains(e.target)) { hideCtx(); document.removeEventListener('mousedown', off, true); } };
document.addEventListener('mousedown', off, true);
}, 0);
}
function showItemCtx(col, idx, x, y) {
const item = data?.columns?.[col]?.items?.[idx];
if (!item) return;
showCtx(x, y, [
['Rename', 'ph-pencil-simple', '', async () => {
const name = await hyprcrd.dialog.show({ title: 'rename bookmark', input: true, defaultValue: item.title || '', okLabel: 'Save' });
if (name === false || name === null) return;
item.title = name;
await save();
renderColumns();
}],
['Delete', 'ph-trash', 'danger', async () => {
data.columns[col].items.splice(idx, 1);
await save();
renderColumns();
}],
]);
}
async function renameColumn(col) {
const column = data?.columns?.[col];
if (!column) return;
const name = await hyprcrd.dialog.show({ title: 'rename column', input: true, defaultValue: column.name, okLabel: 'Save' });
if (name === false || name === null || name.trim() === '') return;
column.name = name.trim();
await save();
renderColumns();
}
function showColCtx(col, x, y) {
const column = data?.columns?.[col];
if (!column) return;
showCtx(x, y, [
['Rename column', 'ph-pencil-simple', '', () => renameColumn(col)],
['Delete column', 'ph-trash', 'danger', async () => {
if (column.items.length) {
const ok = await hyprcrd.dialog.show({ title: 'delete column', message: `"${column.name}" has ${column.items.length} bookmark(s). Delete anyway?`, danger: true, okLabel: 'Delete' });
if (!ok) return;
}
data.columns.splice(col, 1);
if (!data.columns.length) data.columns.push({ name: 'General', items: [] });
await save();
renderColumns();
}],
]);
}
// ── Add bookmark (clipboard + meta-fetch) ────────────────────────
async function addBookmark(colIdx) {
let url = '';
try {
const clip = await navigator.clipboard.readText();
if (isUrl(clip) || isCardLink(clip)) url = clip.trim();
} catch {}
if (!url) {
const entered = await hyprcrd.dialog.show({
title: 'new bookmark',
input: true,
placeholder: 'https://... or card:123',
okLabel: 'Add',
});
if (!entered || !entered.trim()) return;
url = entered.trim();
}
const item = { url, title: url, favicon: '' };
if (isCardLink(url)) {
// Resolve card title for the display label.
const cid = parseCardId(url);
const card = (window.state?.cards || []).find(c => String(c.id) === cid);
if (card) item.title = card.title || url;
} else if (isUrl(url)) {
try {
const meta = await hyprcrd.api('api/bookmarks/fetch-meta?url=' + encodeURIComponent(url));
if (meta?.ok) {
if (meta.title) item.title = meta.title;
if (meta.favicon) item.favicon = meta.favicon;
if (meta.url) item.url = meta.url;
}
} catch {}
}
data.columns[colIdx].items.push(item);
await save();
renderColumns();
}
// ── Add column ───────────────────────────────────────────────────
async function addColumn() {
const name = await hyprcrd.dialog.show({
title: 'new column',
input: true,
placeholder: 'column name',
okLabel: 'Create',
});
if (!name || !name.trim()) return;
data.columns.push({ name: name.trim(), items: [] });
await save();
renderColumns();
// Scroll to the new column
setTimeout(() => { colsEl.scrollLeft = colsEl.scrollWidth; }, 50);
}
// ── Header button injection ──────────────────────────────────────
// Creates a new #bookmarks card (or opens the most recent one).
function injectHeaderButton() {
if ($('bookmarks-btn')) return;
const header = document.querySelector('header#main-header');
if (!header) return;
const rows = header.querySelectorAll('.flex.items-center.gap-3');
const group = rows[rows.length - 1];
if (!group) return;
const btn = document.createElement('button');
btn.id = 'bookmarks-btn';
btn.className = 'flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors';
btn.innerHTML = '<i class="ph ph-link text-sm"></i><span class="font-pixel text-[10px]">Bookmarks</span>';
btn.addEventListener('click', () => {
const cards = (window.state?.cards || []).filter(isBookmarkCard)
.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''));
if (cards.length === 0) {
createBookmarkCard();
} else {
// Always show picker so the user can create new lists
// even when only one exists.
showBookmarkPicker(btn, cards);
}
});
group.insertBefore(btn, group.firstChild);
}
// ── Rename the currently open bookmark card ──────────────────
async function renameCard() {
if (!activeCardId) return;
const card = (window.state?.cards || []).find(c => c.id == activeCardId);
const current = card?.title || '';
const name = await hyprcrd.dialog.show({
title: 'rename bookmark list',
input: true,
defaultValue: current,
okLabel: 'Save',
});
if (name === false || name === null || !name.trim()) return;
await hyprcrd.api(`api/cards/${activeCardId}`, {
method: 'PATCH',
body: { title: name.trim() },
});
$('bookmarks-title').textContent = name.trim();
await window._reloadAll?.();
}
// ── Create bookmark card with name prompt ────────────────────
async function createBookmarkCard() {
const name = await hyprcrd.dialog.show({
title: 'new bookmark list',
input: true,
placeholder: 'list name...',
okLabel: 'Create',
});
if (!name || !name.trim()) return;
const active = window.state?.activeBucket;
const virtualBuckets = ['all', 'none', 'current', 'favorites', 'journal'];
const bucketId = active && !virtualBuckets.includes(active) ? active : null;
const created = await hyprcrd.api('api/cards', {
method: 'POST',
body: {
title: name.trim(),
body: JSON.stringify(emptyData()) + '\n\n#bookmarks',
color: '#38bdf8',
bucket_id: bucketId,
},
});
if (created?.id) {
await window._reloadAll?.();
openOverlay(created.id);
}
}
// ── Picker popup (multiple bookmark cards) ───────────────────
function showBookmarkPicker(anchor, cards) {
document.querySelector('.bm-picker')?.remove();
const rect = anchor.getBoundingClientRect();
const pop = document.createElement('div');
pop.className = 'bm-picker';
pop.style.cssText = `position:fixed;left:${rect.left}px;top:${rect.bottom + 4}px;z-index:200;background:var(--t-surface);border:1px solid var(--t-border);box-shadow:0 4px 20px rgba(0,0,0,0.4);min-width:200px;max-height:300px;display:flex;flex-direction:column;border-radius:var(--ui-radius-modal,4px);`;
const list = document.createElement('div');
list.style.cssText = 'overflow-y:auto;flex:1;';
cards.forEach(c => {
const row = document.createElement('button');
row.style.cssText = 'display:flex;align-items:center;gap:8px;width:100%;padding:8px 12px;background:none;border:none;color:var(--t-muted);font-size:12px;cursor:pointer;text-align:left;';
row.innerHTML = `<span style="width:10px;height:10px;border-radius:2px;background:${c.color || '#38bdf8'};flex-shrink:0;"></span><span style="font-family:var(--t-font-pixel);font-size:10px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(c.title)}</span>`;
row.addEventListener('mouseenter', () => { row.style.color = 'var(--t-bright)'; });
row.addEventListener('mouseleave', () => { row.style.color = 'var(--t-muted)'; });
row.addEventListener('click', () => { pop.remove(); openOverlay(c.id); });
list.appendChild(row);
});
pop.appendChild(list);
const addBtn = document.createElement('button');
addBtn.style.cssText = 'display:flex;align-items:center;gap:6px;width:100%;padding:8px 12px;background:none;border:none;border-top:1px solid var(--t-border);color:var(--t-accent);font-size:10px;font-family:var(--t-font-pixel);cursor:pointer;text-align:left;';
addBtn.innerHTML = '<i class="ph ph-plus text-xs"></i> new list';
addBtn.addEventListener('click', () => { pop.remove(); createBookmarkCard(); });
pop.appendChild(addBtn);
document.body.appendChild(pop);
// Clamp
const r = pop.getBoundingClientRect();
if (r.right > window.innerWidth - 8) pop.style.left = Math.max(8, window.innerWidth - r.width - 8) + 'px';
if (r.bottom > window.innerHeight - 8) pop.style.top = Math.max(8, window.innerHeight - r.height - 8) + 'px';
const close = (e) => { if (!pop.contains(e.target) && e.target !== anchor) { pop.remove(); document.removeEventListener('mousedown', close, true); } };
setTimeout(() => document.addEventListener('mousedown', close, true), 0);
}
// ── Init ─────────────────────────────────────────────────────────
function init() {
injectHeaderButton();
$('bookmarks-close')?.addEventListener('click', closeOverlay);
$('bookmarks-col-add')?.addEventListener('click', addColumn);
$('bookmarks-add-btn')?.addEventListener('click', () => {
if (data?.columns?.length) addBookmark(0);
});
$('bookmarks-title')?.addEventListener('click', renameCard);
overlay?.addEventListener('click', (e) => { if (e.target === overlay) closeOverlay(); });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && overlay?.classList.contains('open')) {
e.stopPropagation();
closeOverlay();
}
}, true);
decorateBoard();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
hyprcrd.registerPlugin?.({
name: 'bookmarks',
version: '1.0.0',
description: 'Column-based bookmark manager stored as cards',
});