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) <noreply@anthropic.com> --- 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 @@ <?php define('APP_NAME', 'hyprcrd'); -define('APP_VERSION', '1.9.22'); +define('APP_VERSION', '1.9.23'); // Database path — writable directory, outside webroot // DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation diff --git a/electron/package.json b/electron/package.json index 58b2203..813a052 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "hyprcrd", - "version": "1.9.22", + "version": "1.9.23", "description": "hyprcrd — self-hosted card manager", "main": "main.js", "scripts": { diff --git a/public/plugins/bookmarks/backend.php b/public/plugins/bookmarks/backend.php new file mode 100644 index 0000000..db7a0cf --- /dev/null +++ b/public/plugins/bookmarks/backend.php @@ -0,0 +1,92 @@ +<?php + +/** + * Bookmarks Plugin: backend. + * + * Single endpoint that fetches a URL's <title> 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 <title> + if (preg_match('/<title[^>]*>(.*?)<\/title>/si', $html, $m)) { + $title = trim(html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8')); + } + // og:title fallback + if ($title === '' && preg_match('/<meta[^>]+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('/<link[^>]+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 @@ +<!-- Bookmarks overlay. Opens when the user clicks a #bookmarks card + on the board (instead of the normal detail view). Column-based + layout; each column is a topic, each item is a bookmark with + favicon + title. --> +<div id="bookmarks-overlay" class="overlay fixed inset-0 z-[80]" style="background:var(--t-overlay)"> + <div class="bookmarks-shell"> + <div class="bookmarks-header"> + <i class="ph ph-link text-t-accent text-base"></i> + <span id="bookmarks-title" class="font-pixel text-sm text-t-accent flex-1 truncate cursor-pointer hover:text-t-bright transition-colors" data-tip="click to rename"></span> + <button id="bookmarks-add-btn" class="bm-btn" data-tip="new bookmark (paste URL)"><i class="ph ph-plus text-sm"></i></button> + <button id="bookmarks-col-add" class="bm-btn" data-tip="new column"><i class="ph ph-columns-plus-right text-sm"></i></button> + <button id="bookmarks-close" class="bm-btn hover:text-t-danger"><i class="ph ph-x text-sm"></i></button> + </div> + <div class="bookmarks-slider-row"> + <input type="range" id="bookmarks-col-width" min="140" max="400" step="10" value="220" class="bookmarks-slider"> + </div> + <div id="bookmarks-columns" class="bookmarks-columns"></div> + </div> +</div> 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, '"').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 = ` + <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', +}); 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'); }); });