v2.0.5 — Read feature (mobile zen reader on the relay)

Per-card opt-in push to the relay for distraction-free mobile reading.
Not tag-based: a real cards.read_push column so the reading state
stays independent of whatever tags the user organises by.

hyprcrd
- cards.read_push column (0/1, default 0) via Database migration.
- POST /api/cards + PATCH /api/cards/{id} accept read_push; save hook
  posts the card (title+body, encrypted) and referenced images
  to the relay's /api/read endpoint. First inline image is flagged
  as the hero. Toggle-off / DELETE wipes the remote copy.
- Detail-bar toggle button ("Read" → "On relay") with filled book
  icon + inverted accent background when active; outline book icon
  in the bottom meta row of the board card when read_push=1.

relay
- read_items + read_attachments tables, encrypted under the
  inter-tenant key like messages (dual-format envelope).
- Client API: POST /api/read, DELETE /api/read/{uid}, POST
  /api/read-attachments. Session API: GET /api/read (list),
  GET /api/read/{uid} (single decrypted), GET /api/read-attachments/
  {uid}/{filename} (serves decrypted image bytes).
- Payload size caps raised to 15MB (attachments) / 2MB (items).
- New nav tab + view: list with thumbnails, click opens a zen reader
  overlay. Reader caps article width at 680px, bumps type to 17px/1.7,
  renders a minimal markdown subset (headings, lists, code fences,
  blockquotes, links, inline images), rewrites uploads/ references
  to the session-authed image endpoint. Escape closes the reader.
- Mobile-first: wider touch targets, tighter padding at ≤640px.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-18 16:10:04 +02:00
1 parent f986a41ccc
commit 1e51bdec7c
12 files changed
+840 -17

No files matched your search

+147 -9
View File
@@ -283,6 +283,97 @@ if (str_starts_with($route, '/api/')) {
return implode(',', array_unique($tags));
}
// Read-later push helpers — called after card insert/update when
// cards.read_push is 1, or when the user toggles it off and we need
// to wipe the remote copy. Failures are logged and swallowed so
// relay trouble never blocks a local save.
function pushCardToRelayRead(array $card, PDO $db): void {
$relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn();
$relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn();
$relayUser = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn();
if (!$relayUrl || !$relayKey || !$relayUser) return;
$relayUid = $card['relay_uid'] ?? null;
if (!$relayUid) {
$relayUid = bin2hex(random_bytes(16));
$db->prepare('UPDATE cards SET relay_uid = ? WHERE id = ?')->execute([$relayUid, $card['id']]);
}
$body = (string)($card['body'] ?? '');
// Hero = first uploads/<hex>.<ext> reference. All inline images
// get pushed as attachments so the reader can resolve them.
preg_match_all('#uploads/([a-f0-9]+\.[a-z0-9]+)#i', $body, $matches);
$imageFilenames = array_unique(array_map('strtolower', $matches[1] ?? []));
$heroFilename = $imageFilenames[0] ?? '';
$payload = json_encode([
'relay_uid' => $relayUid,
'title' => hyprcrdRelayEncrypt((string)($card['title'] ?? ''), $relayKey),
'body' => hyprcrdRelayEncrypt($body, $relayKey),
'hero_filename' => $heroFilename,
]);
$ch = curl_init(rtrim($relayUrl, '/') . '/api/read');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 10,
]);
hyprcrdApplySafeCurlOpts($ch);
$resp = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status < 200 || $status >= 300) {
@error_log('[hyprcrd] read push failed: HTTP ' . $status . ' ' . substr((string)$resp, 0, 200));
return; // don't bother uploading images if the item didn't land
}
// Upload referenced images as read-attachments (encrypted blob,
// same envelope format as collab-attachments).
$uploadDir = UPLOAD_DIR;
foreach ($imageFilenames as $fn) {
$filePath = $uploadDir . '/' . $fn;
if (!is_file($filePath)) continue;
$bytes = file_get_contents($filePath);
if ($bytes === false) continue;
$attPayload = json_encode([
'relay_uid' => $relayUid,
'filename' => $fn,
'data' => hyprcrdRelayEncrypt(base64_encode($bytes), $relayKey),
]);
$ach = curl_init(rtrim($relayUrl, '/') . '/api/read-attachments');
curl_setopt_array($ach, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $attPayload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 30,
]);
hyprcrdApplySafeCurlOpts($ach);
curl_exec($ach);
curl_close($ach);
}
}
function deleteCardFromRelayRead(string $relayUid, PDO $db): void {
if ($relayUid === '') return;
$relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn();
$relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn();
$relayUser = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn();
if (!$relayUrl || !$relayKey || !$relayUser) return;
$ch = curl_init(rtrim($relayUrl, '/') . '/api/read/' . urlencode($relayUid));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 10,
]);
hyprcrdApplySafeCurlOpts($ch);
curl_exec($ch);
curl_close($ch);
}
// Plugin route handlers (registered by plugin backend.php files).
// Each handler is responsible for calling exit() on a match.
PluginManager::instance()->dispatchRoute($route, $method);
@@ -294,7 +385,8 @@ if (str_starts_with($route, '/api/')) {
if ($title === '') { http_response_code(400); echo json_encode(['error' => 'title is required']); exit; }
$pos = $input['position'] ?? 0;
$body = $input['body'] ?? '';
$db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position, tags, due_date, scratchpad) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position, :tags, :due_date, :scratchpad)')->execute([
$readPush = !empty($input['read_push']) ? 1 : 0;
$db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position, tags, due_date, scratchpad, read_push) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position, :tags, :due_date, :scratchpad, :read_push)')->execute([
'title' => $title, 'body' => $body, 'color' => validColor($input['color'] ?? null),
'bucket_id' => validInt($input['bucket_id'] ?? null, null),
'parent_id' => validInt($input['parent_id'] ?? null, null),
@@ -302,8 +394,16 @@ if (str_starts_with($route, '/api/')) {
'tags' => extractTags($body),
'due_date' => $input['due_date'] ?? null,
'scratchpad' => $input['scratchpad'] ?? null,
'read_push' => $readPush,
]);
echo json_encode($db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . $db->lastInsertId())->fetch());
$saved = $db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . $db->lastInsertId())->fetch();
if ($readPush && $saved) {
pushCardToRelayRead($saved, $db);
// relay_uid may have been generated inside the push helper;
// re-read so the client sees it on the response.
$saved = $db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . (int)$saved['id'])->fetch();
}
echo json_encode($saved);
exit;
}
@@ -323,17 +423,21 @@ if (str_starts_with($route, '/api/')) {
}
if ($route === '/api/cards/meta' && $method === 'GET') {
echo json_encode($db->query('SELECT c.id, c.title, c.color, c.tags, c.bucket_id, c.parent_id, c.position, c.created_at, c.updated_at, c.due_date, c.caldav_uid, c.caldav_rrule, c.relay_uid, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id ORDER BY c.position ASC, c.created_at DESC')->fetchAll());
echo json_encode($db->query('SELECT c.id, c.title, c.color, c.tags, c.bucket_id, c.parent_id, c.position, c.created_at, c.updated_at, c.due_date, c.caldav_uid, c.caldav_rrule, c.relay_uid, c.read_push, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id ORDER BY c.position ASC, c.created_at DESC')->fetchAll());
exit;
}
if (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'DELETE') {
// CalDAV tombstone for synced cards
$card = $db->prepare('SELECT caldav_uid FROM cards WHERE id = :id');
// CalDAV tombstone for synced cards, plus a read-list wipe so the
// mobile reader stops showing cards that no longer exist locally.
$card = $db->prepare('SELECT caldav_uid, relay_uid, read_push FROM cards WHERE id = :id');
$card->execute(['id' => $m[1]]);
$uid = $card->fetchColumn();
if ($uid) {
$db->prepare('INSERT OR REPLACE INTO caldav_tombstones (caldav_uid, card_id) VALUES (?, ?)')->execute([$uid, $m[1]]);
$cardRow = $card->fetch();
if ($cardRow && !empty($cardRow['caldav_uid'])) {
$db->prepare('INSERT OR REPLACE INTO caldav_tombstones (caldav_uid, card_id) VALUES (?, ?)')->execute([$cardRow['caldav_uid'], $m[1]]);
}
if ($cardRow && (int)($cardRow['read_push'] ?? 0) === 1 && !empty($cardRow['relay_uid'])) {
deleteCardFromRelayRead((string)$cardRow['relay_uid'], $db);
}
$db->prepare('DELETE FROM cards WHERE id = :id')->execute(['id' => $m[1]]);
echo json_encode(['ok' => true]); exit;
@@ -367,10 +471,41 @@ if (str_starts_with($route, '/api/')) {
foreach (['title', 'body', 'color', 'position', 'bucket_id', 'parent_id', 'due_date', 'scratchpad'] as $f) {
if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $input[$f]; }
}
if (array_key_exists('read_push', $input)) {
$fields[] = 'read_push = :read_push';
$params['read_push'] = !empty($input['read_push']) ? 1 : 0;
}
if (array_key_exists('body', $input)) { $fields[] = 'tags = :tags'; $params['tags'] = extractTags($input['body']); }
// Snapshot the previous read_push / relay_uid before the write so we
// can detect a toggle-off and remove the remote copy afterwards.
// The history-tracking block above only loads $old when a tracked
// field changed; we need it unconditionally when read_push is in
// play, so backfill here if missing.
$prevRead = null; $prevRelayUid = null;
if (array_key_exists('read_push', $input) && !isset($old)) {
$snap = $db->prepare('SELECT read_push, relay_uid FROM cards WHERE id = :id');
$snap->execute(['id' => $m[1]]);
$old = $snap->fetch() ?: null;
}
if (isset($old) && $old) { $prevRead = (int)($old['read_push'] ?? 0); $prevRelayUid = $old['relay_uid'] ?? null; }
if ($fields) { $fields[] = 'updated_at = datetime("now")'; $db->prepare('UPDATE cards SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params); }
$card = $db->prepare('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = :id');
$card->execute(['id' => $m[1]]); echo json_encode($card->fetch()); exit;
$card->execute(['id' => $m[1]]); $savedCard = $card->fetch();
// Relay sync:
// - if card currently wants to be in the read list, push (idempotent
// upsert keeps the remote copy in sync on every edit).
// - if the user just toggled it off, clean up remotely.
if ($savedCard) {
$nowRead = (int)($savedCard['read_push'] ?? 0);
if ($nowRead === 1) {
pushCardToRelayRead($savedCard, $db);
// Re-read to surface any newly-generated relay_uid on the wire.
$card->execute(['id' => $m[1]]); $savedCard = $card->fetch();
} elseif ($prevRead === 1 && $prevRelayUid) {
deleteCardFromRelayRead($prevRelayUid, $db);
}
}
echo json_encode($savedCard); exit;
}
// Plugins
@@ -2224,6 +2359,9 @@ if (str_starts_with($route, '/api/')) {
<button id="detail-zen" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-help="Zen mode\nFull-screen distraction-free reading.\nDrag edges to resize.">
<i class="ph ph-corners-out text-sm"></i>
</button>
<button id="detail-read-toggle" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-help="Read later\nPush this card to your relay account so you can read it in the distraction-free mobile reader.">
<i id="detail-read-icon" class="ph ph-book-open text-sm"></i><span id="detail-read-label" class="font-pixel text-[10px]">Read</span>
</button>
<div class="flex-1"></div>
<button id="split-right-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-help="Split view\nShows cards sharing tags, links,\nor content with this card.">
<i class="ph ph-split-horizontal text-sm"></i><span class="font-pixel text-[10px]">Split</span>