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:
1 parent
883640e20c
commit
82b2ebc15f
10 files changed
+1157
-6
No files matched your search
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user