Restructure the shopping-list feature end to end:
- New public/plugins/shopping/ plugin with backend.php (proxy
routes registered via PluginManager::onApiRoute), shopping.js
(self-contained; injects the header Shopping button, owns the
picker dialog + sync loop via hyprcrd.hooks), plugin.json and a
placeholder css file. Core no longer knows about shopping —
index.php loses the static button and the proxy block, app.js
loses the initShopping wiring, the old js/modules/shopping.js
is gone.
- Phone view moves out of a standalone /shopping page and into
the relay UI as a regular `view-shopping` behind a nav-burger
entry (relay/ui/layout.php + app.js + style.css). Same session
auth as compose/chat/account — no separate login. Auto-refresh
every 4 s while the view is active; no manual refresh button.
Headings "# h1 … ###### h6" render between items for grouping.
Orphan section headings (no open items under them) are hidden.
- Sync: drop timestamp cursors (broke across SQLite-UTC / PHP
timezones and silently blocked pushes after a pull). Both sides
diff the (uid, title, body) triple instead — timezone-agnostic
and authoritative. Client ticks push-before-pull so fresh local
edits land on the relay before a stale pull can overwrite them.
Relay drops its "server newer" guard and stamps server times
with gmdate so anything left comparing to SQLite is UTC.
- Client also ticks on cards.reloaded, not just sync.tick, so
any edit path (editor save, checkbox toggle, context menu,
undo) pushes the update immediately.
- Tests: updated for the plugin (still uses the same selectors
because the plugin injects the same ids), plus a plugin-
registration check and "(no active list)" option assertion.
- Help section copy updated — references the Shopping header
button and the shopping tab in the relay UI rather than the
gone /shopping URL.
Both #einkauf and #shopping work as trigger tags; phone is
check-only, editing stays in hyprcrd. Version bumped to 1.9.21.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
116 lines
5.3 KiB
PHP
116 lines
5.3 KiB
PHP
<?php
|
|
// Shopping-list routes:
|
|
// POST /api/shopping (client) upsert active card {uid, title, body, updated_at}
|
|
// GET /api/shopping (client + session) read current state
|
|
// POST /api/shopping/check (session) toggle one line {line, checked}
|
|
// GET /shopping (session) phone-friendly UI
|
|
//
|
|
// Plaintext on purpose — shopping-list content is low-stakes and
|
|
// everything here is session- or client-auth'd. No per-tenant key
|
|
// dance like chat / collab-buckets. If that needs tightening later,
|
|
// it's a one-table migration.
|
|
|
|
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|
|
|
$clientTenant = $clientTenant ?? authClient($db);
|
|
$tenant = $sessionTenant ?: $clientTenant;
|
|
|
|
function shoppingLoad(PDO $db, int $tenantId): ?array {
|
|
$stmt = $db->prepare('SELECT card_uid, title, body, updated_at_client, updated_at_server FROM shopping_state WHERE tenant_id = :t');
|
|
$stmt->execute(['t' => $tenantId]);
|
|
$row = $stmt->fetch();
|
|
return $row ?: null;
|
|
}
|
|
|
|
// ── POST /api/shopping ────────────────────────────────────────
|
|
// Client pushes the currently-active card's content. `updated_at`
|
|
// is the client's timestamp — used for lose-the-race checks.
|
|
|
|
if ($route === '/api/shopping' && $method === 'POST') {
|
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
|
$uid = trim((string)($input['card_uid'] ?? ''));
|
|
$title = (string)($input['title'] ?? '');
|
|
$body = (string)($input['body'] ?? '');
|
|
$clientTs = trim((string)($input['updated_at'] ?? '')) ?: gmdate('Y-m-d H:i:s');
|
|
if ($uid === '') jsonResponse(['error' => 'card_uid required'], 400);
|
|
|
|
// No server-side staleness guard: the client decides whether to
|
|
// push by diffing its last-synced body against the current card,
|
|
// not by timestamp, so cross-timezone clock comparisons can't
|
|
// silently block edits. Last-writer per tick wins — phone-toggle
|
|
// races lose at most one tick of flips (rare by design).
|
|
$now = gmdate('Y-m-d H:i:s');
|
|
$db->prepare('
|
|
INSERT INTO shopping_state (tenant_id, card_uid, title, body, updated_at_client, updated_at_server)
|
|
VALUES (:t, :u, :ti, :b, :c, :s)
|
|
ON CONFLICT(tenant_id) DO UPDATE SET
|
|
card_uid = excluded.card_uid,
|
|
title = excluded.title,
|
|
body = excluded.body,
|
|
updated_at_client = excluded.updated_at_client,
|
|
updated_at_server = excluded.updated_at_server
|
|
')->execute([
|
|
't' => $clientTenant['id'],
|
|
'u' => $uid,
|
|
'ti' => $title,
|
|
'b' => $body,
|
|
'c' => $clientTs,
|
|
's' => $now,
|
|
]);
|
|
jsonResponse(['ok' => true, 'stored' => true, 'updated_at_server' => $now]);
|
|
}
|
|
|
|
// ── GET /api/shopping ────────────────────────────────────────
|
|
// Both the client (to pull phone-made toggles back) and the
|
|
// session-auth'd phone UI poll this.
|
|
|
|
if ($route === '/api/shopping' && $method === 'GET') {
|
|
if (!$tenant) jsonResponse(['error' => 'unauthorized'], 401);
|
|
$state = shoppingLoad($db, (int)$tenant['id']);
|
|
if (!$state) jsonResponse(['ok' => true, 'state' => null]);
|
|
jsonResponse(['ok' => true, 'state' => $state]);
|
|
}
|
|
|
|
// ── POST /api/shopping/check ─────────────────────────────────
|
|
// Phone-side toggle. Body: { line: "- [ ] milk", checked: true }.
|
|
// We match the exact line once to keep merges simple; if the line
|
|
// isn't found (hyprcrd just rewrote it), the caller reloads and
|
|
// retries. Bumps updated_at_server so the next client pull sees
|
|
// the change.
|
|
|
|
if ($route === '/api/shopping/check' && $method === 'POST') {
|
|
if (!$sessionTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
|
$line = (string)($input['line'] ?? '');
|
|
$checked = !empty($input['checked']);
|
|
if ($line === '') jsonResponse(['error' => 'line required'], 400);
|
|
|
|
$state = shoppingLoad($db, (int)$sessionTenant['id']);
|
|
if (!$state) jsonResponse(['error' => 'no active list'], 404);
|
|
$body = $state['body'];
|
|
$lines = preg_split("/\r\n|\n|\r/", $body);
|
|
$hit = false;
|
|
foreach ($lines as $i => $l) {
|
|
if ($l !== $line) continue;
|
|
$toggled = $checked
|
|
? preg_replace('/^(\s*-\s*)\[\s\]/', '$1[x]', $l, 1)
|
|
: preg_replace('/^(\s*-\s*)\[[xX]\]/', '$1[ ]', $l, 1);
|
|
if ($toggled === $l) continue; // not a checkbox line
|
|
$lines[$i] = $toggled;
|
|
$hit = true;
|
|
break;
|
|
}
|
|
if (!$hit) jsonResponse(['error' => 'line not found (list may have been edited — reload)'], 409);
|
|
|
|
$newBody = implode("\n", $lines);
|
|
$now = gmdate('Y-m-d H:i:s');
|
|
$db->prepare('UPDATE shopping_state SET body = :b, updated_at_server = :s WHERE tenant_id = :t')
|
|
->execute(['b' => $newBody, 's' => $now, 't' => $sessionTenant['id']]);
|
|
jsonResponse(['ok' => true, 'updated_at_server' => $now]);
|
|
}
|
|
|
|
// Shopping list lives inside the relay UI as a regular view (see
|
|
// relay/ui/layout.php + app.js). No separate /shopping page — login
|
|
// and navigation are the same as for every other relay feature.
|