plugin(collab-buckets): collaborative buckets via relay

End-to-end shared buckets between relay tenants. Bucket metadata + members
sync through new relay routes (collab-buckets, collab-cards, collab-locks);
card content rides the existing inter-tenant encryption hop. Edit locks,
ownership transfer, LWW conflict stashing, undo for local-bucket destructive
actions, "Make collaborative…" upgrade path, and a resilient outbox for
relay-down create attempts. See plugins/collab-buckets/README.md and
TODO.md for the full picture.

Core hooks added so the plugin can integrate without monkey-patching:
- buckets.js context menu: collab-aware rename/color/delete + Members &
  owner / Make collaborative entries
- editor.js dispatches hyprcrd:editor-open / hyprcrd:editor-close for the
  per-card edit-lock heartbeat
- app.js exposes window.state for the plugin's MutationObserver
- index.php POST/PATCH/DELETE on /api/buckets refuse to mutate collab
  buckets directly (must go through the relay)
- Database.php adds buckets.collab_uid column + unique partial index
- import handler now restores color and collab_uid (was silently dropping
  color, which caused the test runner's auto-restore to wipe custom
  bucket colors)
- relay bootstrap creates collab_buckets, collab_bucket_members,
  collab_cards, collab_card_locks tables and prunes tombstones / expired
  locks on every request

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-11 18:49:53 +02:00
1 parent a29708df85
commit 26ffa0e71f
16 files changed
+3017 -13

No files matched your search

+28 -3
View File
@@ -341,19 +341,38 @@ if (str_starts_with($route, '/api/')) {
$input = json_decode(file_get_contents('php://input'), true);
$name = trim($input['name'] ?? '');
if ($name === '') { http_response_code(400); echo json_encode(['error' => 'name is required']); exit; }
// Normal POST creates a local bucket. Collab buckets go through
// /api/collab-buckets (plugin-owned) and can't be created here.
$db->prepare('INSERT INTO buckets (name) VALUES (:name)')->execute(['name' => $name]);
echo json_encode($db->query('SELECT * FROM buckets WHERE id = ' . $db->lastInsertId())->fetch()); exit;
}
if (preg_match('#^/api/buckets/(\d+)$#', $route, $m) && $method === 'PATCH') {
$input = json_decode(file_get_contents('php://input'), true);
// Collab buckets: name and color must go through the relay plugin so
// all members see the update. Only position (local reorder) is allowed.
$b = $db->prepare('SELECT collab_uid FROM buckets WHERE id = :id');
$b->execute(['id' => $m[1]]);
$existing = $b->fetch();
$isCollab = $existing && !empty($existing['collab_uid']);
$allowed = $isCollab ? ['position'] : ['name', 'position', 'color'];
$fields = []; $params = ['id' => $m[1]];
foreach (['name', 'position', 'color'] as $f) { if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $f === 'color' ? validColor($input[$f]) : $input[$f]; } }
foreach ($allowed as $f) { if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $f === 'color' ? validColor($input[$f]) : $input[$f]; } }
if ($fields) $db->prepare('UPDATE buckets SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params);
$b = $db->prepare('SELECT * FROM buckets WHERE id = :id'); $b->execute(['id' => $m[1]]); echo json_encode($b->fetch()); exit;
}
if (preg_match('#^/api/buckets/(\d+)$#', $route, $m) && $method === 'DELETE') {
// Collab buckets must go through /api/collab-buckets/{uid} so the relay
// and plugin mirror stay consistent. Refuse deletion here.
$b = $db->prepare('SELECT collab_uid FROM buckets WHERE id = :id');
$b->execute(['id' => $m[1]]);
$existing = $b->fetch();
if ($existing && !empty($existing['collab_uid'])) {
http_response_code(400);
echo json_encode(['error' => 'collab buckets must be deleted via /api/collab-buckets/' . $existing['collab_uid']]);
exit;
}
$db->prepare('UPDATE cards SET bucket_id = NULL WHERE bucket_id = :id')->execute(['id' => $m[1]]);
$db->prepare('DELETE FROM buckets WHERE id = :id')->execute(['id' => $m[1]]);
echo json_encode(['ok' => true]); exit;
@@ -625,8 +644,14 @@ if (str_starts_with($route, '/api/')) {
$found = $existing->fetchColumn();
if ($found) { $bucketMap[$oldId] = $found; continue; }
}
$stmt = $db->prepare('INSERT INTO buckets (name, position, created_at) VALUES (:name, :position, :created_at)');
$stmt->execute(['name' => $b['name'], 'position' => $b['position'] ?? 0, 'created_at' => $b['created_at'] ?? date('Y-m-d H:i:s')]);
$stmt = $db->prepare('INSERT INTO buckets (name, position, color, collab_uid, created_at) VALUES (:name, :position, :color, :collab_uid, :created_at)');
$stmt->execute([
'name' => $b['name'],
'position' => $b['position'] ?? 0,
'color' => $b['color'] ?? '#64748b',
'collab_uid' => $b['collab_uid'] ?? null,
'created_at' => $b['created_at'] ?? date('Y-m-d H:i:s'),
]);
$bucketMap[$oldId] = $db->lastInsertId();
}