bookmarks: column-based bookmark manager plugin

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 <title> + 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>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-16 12:33:18 +02:00
1 parent 883640e20c
commit 82b2ebc15f
10 files changed
+1157 -6

No files matched your search

+154
View File
@@ -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();
}
});
});