From 82b2ebc15f1c496bda6ed3ced6fdee9ae7708857 Mon Sep 17 00:00:00 2001
From: th
Date: Thu, 16 Apr 2026 12:33:18 +0200
Subject: [PATCH] bookmarks: column-based bookmark manager plugin
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
New plugin at public/plugins/bookmarks/ — bookmark lists stored as
cards tagged #bookmarks with a JSON body. Board cards render as
coloured tiles (70% card colour + surface) with a centred link icon,
title, and date/bucket metadata; no body preview.
Clicking opens a full-screen overlay with:
- Named columns (click header to rename, right-click for
rename + delete), + button to add columns
- Bookmark items: favicon (stored as data: URI via server-side
fetch-meta) + title with ellipsis. Click opens URL in new tab.
- Right-click item → rename or delete
- Positional drag-and-drop between and within columns — accent
drop-indicator line shows the insertion point
- Full-width slider sets relative column width, persisted per card
- Horizontal wheel-to-scroll on the column container
Adding a bookmark reads the clipboard; if it contains a URL, the
backend fetches + favicon (as base64 data: URI, ≤32 KB) so
the card is fully self-contained. Non-URL clipboard → manual entry
dialog. Also accepts card:123 links — rendered with a card icon in
the linked card's colour; click opens the linked card's detail view
or, if it's another bookmark list, switches the overlay to it.
Multiple bookmark lists supported: header button always shows a
picker (even with one list) with a "new list" option. New lists
inherit the active bucket. Overlay title is click-to-rename.
Tests: plugin registration, header button injection, board card
decoration, overlay open/close + column + item rendering, fetch-meta
API shape, card-link icon rendering, column-width persistence.
Also fixes the flaky undo due_date test — replaced a fixed sleep
with waitForStateCard polling so the undo PATCH has time to settle.
Bumps to 1.9.23.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
config.php | 2 +-
electron/package.json | 2 +-
public/plugins/bookmarks/backend.php | 92 ++++
public/plugins/bookmarks/bookmarks.css | 260 ++++++++++
public/plugins/bookmarks/bookmarks.html | 19 +
public/plugins/bookmarks/bookmarks.js | 614 ++++++++++++++++++++++++
public/plugins/bookmarks/plugin.json | 11 +
public/plugins/tests/bookmarks.test.js | 154 ++++++
public/plugins/tests/tests.js | 1 +
public/plugins/tests/undo.test.js | 8 +-
10 files changed, 1157 insertions(+), 6 deletions(-)
create mode 100644 public/plugins/bookmarks/backend.php
create mode 100644 public/plugins/bookmarks/bookmarks.css
create mode 100644 public/plugins/bookmarks/bookmarks.html
create mode 100644 public/plugins/bookmarks/bookmarks.js
create mode 100644 public/plugins/bookmarks/plugin.json
create mode 100644 public/plugins/tests/bookmarks.test.js
diff --git a/config.php b/config.php
index a073478..515a2c6 100644
--- a/config.php
+++ b/config.php
@@ -1,7 +1,7 @@
and favicon so the
+ * frontend doesn't have to deal with CORS. Light and stateless —
+ * the bookmarks themselves live in the card body (JSON blob).
+ *
+ * Routes:
+ * GET /api/bookmarks/fetch-meta?url=... → { title, favicon }
+ */
+
+PluginManager::instance()->onApiRoute(function (string $route, string $method) {
+ if ($route !== '/api/bookmarks/fetch-meta' || $method !== 'GET') return;
+
+ $url = trim((string)($_GET['url'] ?? ''));
+ if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) {
+ http_response_code(400);
+ echo json_encode(['error' => 'valid url required']);
+ exit;
+ }
+
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_MAXREDIRS => 5,
+ CURLOPT_TIMEOUT => 8,
+ CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; hyprcrd-bookmarks/1.0)',
+ CURLOPT_SSL_VERIFYPEER => true,
+ ]);
+ $html = curl_exec($ch);
+ $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $finalUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL) ?: $url;
+ curl_close($ch);
+
+ $title = '';
+ if ($html && $status >= 200 && $status < 400) {
+ // Extract
+ if (preg_match('/]*>(.*?)<\/title>/si', $html, $m)) {
+ $title = trim(html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
+ }
+ // og:title fallback
+ if ($title === '' && preg_match('/]+property=["\']og:title["\'][^>]+content=["\']([^"\']+)/si', $html, $m)) {
+ $title = trim(html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
+ }
+ }
+
+ // Favicon: resolve the URL, then download the bytes and return a
+ // self-contained data: URI so the card body is fully offline-capable
+ // and opening the overlay doesn't leak requests to every bookmarked
+ // site. Caps at 32 KB — anything bigger is probably not an icon.
+ $parsed = parse_url($finalUrl);
+ $origin = ($parsed['scheme'] ?? 'https') . '://' . ($parsed['host'] ?? '');
+ $faviconUrl = $origin . '/favicon.ico';
+ if ($html && preg_match('/]+rel=["\'](?:shortcut )?icon["\'][^>]+href=["\']([^"\']+)/si', $html, $m)) {
+ $href = $m[1];
+ if (str_starts_with($href, '//')) $href = ($parsed['scheme'] ?? 'https') . ':' . $href;
+ elseif (str_starts_with($href, '/')) $href = $origin . $href;
+ elseif (!str_starts_with($href, 'http')) $href = rtrim($finalUrl, '/') . '/' . $href;
+ $faviconUrl = $href;
+ }
+
+ $faviconData = '';
+ $ch2 = curl_init($faviconUrl);
+ curl_setopt_array($ch2, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_MAXREDIRS => 3,
+ CURLOPT_TIMEOUT => 5,
+ CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; hyprcrd-bookmarks/1.0)',
+ ]);
+ $favBytes = curl_exec($ch2);
+ $favStatus = curl_getinfo($ch2, CURLINFO_HTTP_CODE);
+ $favMime = curl_getinfo($ch2, CURLINFO_CONTENT_TYPE) ?: 'image/x-icon';
+ curl_close($ch2);
+
+ if ($favBytes && $favStatus >= 200 && $favStatus < 400 && strlen($favBytes) <= 32768) {
+ $mime = preg_replace('/;.*$/', '', $favMime);
+ $faviconData = 'data:' . $mime . ';base64,' . base64_encode($favBytes);
+ }
+
+ header('Content-Type: application/json');
+ echo json_encode([
+ 'ok' => true,
+ 'url' => $finalUrl,
+ 'title' => mb_substr($title, 0, 200),
+ 'favicon' => $faviconData,
+ ]);
+ exit;
+});
diff --git a/public/plugins/bookmarks/bookmarks.css b/public/plugins/bookmarks/bookmarks.css
new file mode 100644
index 0000000..af622e3
--- /dev/null
+++ b/public/plugins/bookmarks/bookmarks.css
@@ -0,0 +1,260 @@
+/* ── Bookmarks plugin ── */
+
+/* Board card override: the MutationObserver replaces the card's
+ innerHTML with a coloured tile (card.color as bg) showing a
+ centered link icon + the title. No body preview, no tags, no
+ metadata — just a clean, recognisable tile. */
+.board-card.bm-card {
+ border: none !important;
+ cursor: pointer;
+ overflow: hidden;
+}
+.bm-card-inner {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 100px;
+}
+.bm-card-center {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ padding: 20px 14px 8px;
+}
+.bm-card-icon {
+ font-size: 42px;
+ color: rgba(0,0,0,0.2);
+}
+.bm-card-title {
+ font-family: var(--t-font-pixel);
+ font-size: 11px;
+ color: rgba(0,0,0,0.55);
+ text-align: center;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 100%;
+}
+.bm-card-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 12px;
+ font-family: var(--t-font-pixel);
+ font-size: 9px;
+ color: rgba(255,255,255,0.55);
+ text-shadow: 0 1px 3px rgba(0,0,0,0.3);
+}
+.bm-card-meta i { font-size: 10px; margin-right: 2px; }
+
+/* Overlay shell */
+.bookmarks-shell {
+ position: absolute;
+ inset: 40px;
+ background: var(--t-surface);
+ border: 1px solid var(--t-border);
+ border-radius: var(--ui-radius-modal, 4px);
+ box-shadow: 0 8px 40px rgba(0,0,0,0.5);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.bookmarks-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 16px;
+ border-bottom: 1px solid var(--t-border);
+ background: var(--t-bg);
+ flex-shrink: 0;
+}
+.bm-btn {
+ background: none;
+ border: 1px solid var(--t-border);
+ color: var(--t-muted);
+ padding: 4px 8px;
+ cursor: pointer;
+ transition: border-color 0.12s, color 0.12s;
+}
+.bm-btn:hover { border-color: var(--t-accent); color: var(--t-accent); }
+
+/* Column width slider — full width */
+.bookmarks-slider-row {
+ padding: 6px 16px;
+ border-bottom: 1px solid var(--t-border);
+ background: var(--t-bg);
+ flex-shrink: 0;
+}
+.bookmarks-slider {
+ width: 100%;
+ height: 4px;
+ -webkit-appearance: none;
+ appearance: none;
+ background: var(--t-border);
+ border-radius: 2px;
+ outline: none;
+ cursor: pointer;
+}
+.bookmarks-slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 14px; height: 14px; border-radius: 50%;
+ background: var(--t-accent); cursor: pointer; border: none;
+}
+.bookmarks-slider::-moz-range-thumb {
+ width: 14px; height: 14px; border-radius: 50%;
+ background: var(--t-accent); cursor: pointer; border: none;
+}
+
+/* Column container — horizontal scroll, wheel redirected by JS */
+.bookmarks-columns {
+ flex: 1;
+ display: flex;
+ gap: 12px;
+ padding: 14px 16px;
+ overflow-x: auto;
+ overflow-y: hidden;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+.bookmarks-columns::-webkit-scrollbar { display: none; }
+
+/* Single column */
+.bm-col {
+ flex: 0 0 var(--bm-col-w, 220px);
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ min-width: 0;
+}
+.bm-col-header {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 6px;
+ border-bottom: 1px solid var(--t-border);
+ flex-shrink: 0;
+}
+.bm-col-header .bm-col-name {
+ font-family: var(--t-font-pixel);
+ font-size: 10px;
+ color: var(--t-bright);
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ cursor: pointer;
+ transition: color 0.12s;
+}
+.bm-col-header .bm-col-name:hover { color: var(--t-accent); }
+.bm-col-header .bm-col-add {
+ background: none;
+ border: none;
+ color: var(--t-dim);
+ cursor: pointer;
+ font-size: 12px;
+ padding: 2px;
+ transition: color 0.12s;
+}
+.bm-col-header .bm-col-add:hover { color: var(--t-accent); }
+
+.bm-col-items {
+ flex: 1;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+/* Bookmark item */
+.bm-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 10px;
+ background: var(--t-raised, var(--t-surface));
+ border: 1px solid var(--t-border);
+ border-radius: var(--ui-radius-card, 3px);
+ cursor: pointer;
+ user-select: none;
+ transition: border-color 0.12s, background 0.12s;
+ min-width: 0;
+}
+.bm-item:hover {
+ border-color: var(--t-accent);
+ background: color-mix(in srgb, var(--t-accent) 6%, var(--t-surface));
+}
+.bm-item .bm-favicon {
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+ border-radius: 2px;
+ object-fit: contain;
+}
+.bm-item .bm-favicon-placeholder {
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--t-dim);
+ font-size: 12px;
+}
+.bm-item .bm-item-title {
+ flex: 1;
+ font-size: 11px;
+ color: var(--t-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.bm-item:hover .bm-item-title { color: var(--t-bright); }
+.bm-item.dragging { opacity: 0.4; }
+.bm-item.bm-drop-above {
+ box-shadow: 0 -2px 0 0 var(--t-accent);
+}
+.bm-item.bm-drop-below {
+ box-shadow: 0 2px 0 0 var(--t-accent);
+}
+
+/* Context menu */
+.bm-ctx {
+ position: fixed;
+ z-index: 300;
+ background: var(--t-surface);
+ border: 1px solid var(--t-border);
+ box-shadow: 0 4px 20px rgba(0,0,0,0.4);
+ border-radius: var(--ui-radius-modal, 4px);
+ min-width: 140px;
+ padding: 4px 0;
+}
+.bm-ctx button {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ padding: 6px 12px;
+ background: none;
+ border: none;
+ color: var(--t-muted);
+ font-size: 11px;
+ cursor: pointer;
+ text-align: left;
+ transition: background 0.08s, color 0.08s;
+}
+.bm-ctx button:hover { background: var(--t-raised); color: var(--t-bright); }
+.bm-ctx button.danger:hover { color: var(--t-danger); }
+
+/* Empty column state */
+.bm-col-empty {
+ padding: 16px 8px;
+ text-align: center;
+ font-size: 10px;
+ color: var(--t-dim);
+ font-family: var(--t-font-pixel);
+}
diff --git a/public/plugins/bookmarks/bookmarks.html b/public/plugins/bookmarks/bookmarks.html
new file mode 100644
index 0000000..00ff4a1
--- /dev/null
+++ b/public/plugins/bookmarks/bookmarks.html
@@ -0,0 +1,19 @@
+
+
diff --git a/public/plugins/bookmarks/bookmarks.js b/public/plugins/bookmarks/bookmarks.js
new file mode 100644
index 0000000..868738a
--- /dev/null
+++ b/public/plugins/bookmarks/bookmarks.js
@@ -0,0 +1,614 @@
+// ── 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, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''');
+}
+
+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 },
+ });
+}
+
+// ── 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 = `
+
+
+
+ ${esc(card.title)}
+
+
+ ${esc(date)}
+ ${bucket ? `${esc(bucket)}` : ''}
+
+
+ `;
+ 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 = `
+ ${esc(col.name)}
+
+ `;
+ 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 = 'no bookmarks ';
+ } 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 = ``;
+ } else {
+ fav = it.favicon
+ ? ` `
+ : '';
+ }
+ el.innerHTML = `${fav}${esc(it.title || it.url)}`;
+ 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 = `${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 = 'Bookmarks';
+ 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 = `${esc(c.title)}`;
+ 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 = ' 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',
+});
diff --git a/public/plugins/bookmarks/plugin.json b/public/plugins/bookmarks/plugin.json
new file mode 100644
index 0000000..0e288c7
--- /dev/null
+++ b/public/plugins/bookmarks/plugin.json
@@ -0,0 +1,11 @@
+{
+ "name": "bookmarks",
+ "version": "1.0.0",
+ "description": "Bookmark manager stored as cards. Column-based view with favicon fetch, clipboard paste, and drag-and-drop.",
+ "frontend": {
+ "js": ["bookmarks.js"],
+ "css": ["bookmarks.css"],
+ "html": ["bookmarks.html"]
+ },
+ "backend": "backend.php"
+}
diff --git a/public/plugins/tests/bookmarks.test.js b/public/plugins/tests/bookmarks.test.js
new file mode 100644
index 0000000..78858eb
--- /dev/null
+++ b/public/plugins/tests/bookmarks.test.js
@@ -0,0 +1,154 @@
+import { describe, it, expect, $, click, sleep, waitFor, csrf } from './runner.js?v=4';
+
+// Bookmarks plugin tests. The plugin injects a header button, decorates
+// #bookmarks-tagged board cards, and opens a column-based overlay.
+// Tests create throwaway cards and clean up in finally blocks.
+
+const token = () => csrf();
+const hJson = () => ({ 'Content-Type': 'application/json', 'X-CSRF-Token': token() });
+const hDel = () => ({ 'X-CSRF-Token': token() });
+
+function emptyBody() {
+ return JSON.stringify({ type: 'bookmarks', columns: [{ name: 'General', items: [] }], columnWidth: 220 }) + '\n\n#bookmarks';
+}
+
+function bodyWith(columns, columnWidth = 220) {
+ return JSON.stringify({ type: 'bookmarks', columns, columnWidth }) + '\n\n#bookmarks';
+}
+
+describe('Bookmarks: plugin registration', () => {
+ it('registers itself in window.hyprcrd.plugins', () => {
+ const entry = (window.hyprcrd?.plugins || []).find(p => p?.name === 'bookmarks');
+ expect(entry).toExist();
+ });
+});
+
+describe('Bookmarks: header button', () => {
+ it('injects a Bookmarks button into the header', () => {
+ const btn = $('#bookmarks-btn');
+ expect(btn).toExist();
+ expect(btn.closest('#main-header')).toExist();
+ });
+});
+
+describe('Bookmarks: board card rendering', () => {
+ it('decorates #bookmarks cards with bm-card class', async () => {
+ let card;
+ try {
+ card = await fetch('api/cards', { method: 'POST', headers: hJson(), body: JSON.stringify({ title: '__test_bm_deco__', body: emptyBody(), color: '#fb923c' }) }).then(r => r.json());
+ expect(card?.id).toExist();
+ await window._reloadAll();
+ await sleep(200);
+ const el = document.querySelector(`.board-card[data-id="${card.id}"]`);
+ expect(el).toExist();
+ expect(el.classList.contains('bm-card')).toBe(true);
+ expect(el.querySelector('.bm-card-icon')).toExist();
+ expect(el.querySelector('.bm-card-title').textContent).toContain('__test_bm_deco__');
+ } finally {
+ if (card?.id) await fetch(`api/cards/${card.id}`, { method: 'DELETE', headers: hDel() });
+ await window._reloadAll();
+ }
+ });
+});
+
+describe('Bookmarks: overlay', () => {
+ it('opens on click and shows columns from the card body', async () => {
+ const cols = [
+ { name: 'TestCol', items: [{ url: 'https://example.com', title: 'Example', favicon: '' }] }
+ ];
+ let card;
+ try {
+ card = await fetch('api/cards', { method: 'POST', headers: hJson(), body: JSON.stringify({ title: '__test_bm_overlay__', body: bodyWith(cols) }) }).then(r => r.json());
+ await window._reloadAll();
+ await sleep(200);
+ const el = document.querySelector(`.board-card[data-id="${card.id}"]`);
+ expect(el).toExist();
+ click(el);
+ await sleep(150);
+ const overlay = $('#bookmarks-overlay');
+ expect(overlay.classList.contains('open')).toBe(true);
+ expect($('bookmarks-title').textContent).toContain('__test_bm_overlay__');
+ // Column rendered
+ const colName = document.querySelector('.bm-col-name');
+ expect(colName).toExist();
+ expect(colName.textContent).toBe('TestCol');
+ // Item rendered
+ const item = document.querySelector('.bm-item');
+ expect(item).toExist();
+ expect(item.textContent).toContain('Example');
+ // Close
+ click($('bookmarks-close'));
+ await sleep(100);
+ expect(overlay.classList.contains('open')).toBe(false);
+ } finally {
+ if (card?.id) await fetch(`api/cards/${card.id}`, { method: 'DELETE', headers: hDel() });
+ await window._reloadAll();
+ }
+ });
+});
+
+describe('Bookmarks: fetch-meta API', () => {
+ it('returns JSON with ok field for a valid URL', async () => {
+ const r = await fetch('api/bookmarks/fetch-meta?url=' + encodeURIComponent('https://example.com')).then(x => x.json());
+ expect(r.ok).toBe(true);
+ expect(typeof r.title).toBe('string');
+ // favicon is either a data: URI or empty string
+ expect(typeof r.favicon).toBe('string');
+ });
+
+ it('returns 400 for missing URL', async () => {
+ const r = await fetch('api/bookmarks/fetch-meta');
+ expect(r.status).toBe(400);
+ });
+});
+
+describe('Bookmarks: card links', () => {
+ it('renders card:ID items with a card icon', async () => {
+ let target, bm;
+ try {
+ target = await fetch('api/cards', { method: 'POST', headers: hJson(), body: JSON.stringify({ title: '__test_bm_target__', body: 'hello' }) }).then(r => r.json());
+ const cols = [{ name: 'Links', items: [{ url: `card:${target.id}`, title: '__test_bm_target__', favicon: '' }] }];
+ bm = await fetch('api/cards', { method: 'POST', headers: hJson(), body: JSON.stringify({ title: '__test_bm_cardlink__', body: bodyWith(cols) }) }).then(r => r.json());
+ await window._reloadAll();
+ await sleep(200);
+ const el = document.querySelector(`.board-card[data-id="${bm.id}"]`);
+ click(el);
+ await sleep(150);
+ const item = document.querySelector('.bm-item');
+ expect(item).toExist();
+ // Card links get a ph-card icon, not ph-globe
+ const cardIcon = item.querySelector('.ph-card');
+ expect(cardIcon).toExist();
+ click($('bookmarks-close'));
+ await sleep(80);
+ } finally {
+ for (const c of [target, bm]) {
+ if (c?.id) await fetch(`api/cards/${c.id}`, { method: 'DELETE', headers: hDel() });
+ }
+ await window._reloadAll();
+ }
+ });
+});
+
+describe('Bookmarks: column width persistence', () => {
+ it('saves and restores columnWidth from card body', async () => {
+ const cols = [{ name: 'W', items: [] }];
+ let card;
+ try {
+ card = await fetch('api/cards', { method: 'POST', headers: hJson(), body: JSON.stringify({ title: '__test_bm_width__', body: bodyWith(cols, 300) }) }).then(r => r.json());
+ await window._reloadAll();
+ await sleep(200);
+ const el = document.querySelector(`.board-card[data-id="${card.id}"]`);
+ click(el);
+ await sleep(150);
+ const slider = $('#bookmarks-col-width');
+ expect(slider).toExist();
+ expect(parseInt(slider.value)).toBe(300);
+ click($('bookmarks-close'));
+ await sleep(80);
+ } finally {
+ if (card?.id) await fetch(`api/cards/${card.id}`, { method: 'DELETE', headers: hDel() });
+ await window._reloadAll();
+ }
+ });
+});
diff --git a/public/plugins/tests/tests.js b/public/plugins/tests/tests.js
index e92f50b..405adb2 100644
--- a/public/plugins/tests/tests.js
+++ b/public/plugins/tests/tests.js
@@ -56,6 +56,7 @@ const FILES = [
'./theme.test.js',
'./chat.test.js',
'./shopping.test.js',
+ './bookmarks.test.js',
];
// Await all imports so the runner can start in a deterministic state.
diff --git a/public/plugins/tests/undo.test.js b/public/plugins/tests/undo.test.js
index f1a8062..a3aeab6 100644
--- a/public/plugins/tests/undo.test.js
+++ b/public/plugins/tests/undo.test.js
@@ -160,11 +160,11 @@ describe('Undo: Editor Save', () => {
let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
expect(card.due_date).toBe(null);
- // Undo should restore the due date
+ // Undo should restore the due date. The undo PATCH +
+ // _reloadAll is async — poll state.cards instead of a fixed
+ // sleep so slow environments don't flake.
ctrlZ();
- await sleep(400);
-
- card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
+ card = await waitForStateCard(res.id, c => c.due_date === '2099-06-15', 3000);
expect(card.due_date).toBe('2099-06-15');
});
});
|