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>
93 lines
3.5 KiB
PHP
93 lines
3.5 KiB
PHP
<?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;
|
|
});
|