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:
1 parent
f986a41ccc
commit
1e51bdec7c
12 files changed
+840
-17
No files matched your search
+147
-9
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user