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();
}
+3
View File
@@ -39,6 +39,9 @@ window._getSelectedIds = getSelectedIds;
window._clearSelection = clearSelection;
window._refreshTasks = refreshTasksView;
window._reloadAll = async () => { await loadBuckets(); state.cards = await api('api/cards') || []; renderBoard(); refreshCalendar(); };
// Exposed for plugins that need to read the shared state (e.g. collab-buckets
// reads `state.buckets` in its MutationObserver to find collab_uid mapping).
window.state = state;
window._extractToCard = async (text) => {
if (!text?.trim()) return;
const lines = text.trim().split('\n');
+86 -10
View File
@@ -3,6 +3,7 @@
import { $, esc, api } from './util.js';
import { state, CARD_COLORS } from './state.js';
import { showDialog } from './dialog.js';
import { pushUndo } from './undo.js';
// ── Bucket context menu ──
@@ -24,9 +25,18 @@ async function showBucketCtx(x, y, bucket) {
const canRight = idx < state.buckets.length - 1;
const hasCards = state.cards.some(c => c.bucket_id == bucket.id);
const isCollab = !!bucket.collab_uid;
ctxMenu.innerHTML = `<div class="py-1">
<button class="ctx-item" data-baction="rename"><i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">Rename</span></button>
<button class="ctx-item" data-baction="color"><i class="ph ph-palette text-sm"></i><span class="font-pixel text-[10px]">Color</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
${isCollab && typeof window._collabBucketsEditDialog === 'function'
? `<button class="ctx-item" data-baction="collab-manage"><i class="ph ph-users-three text-sm"></i><span class="font-pixel text-[10px]">Members &amp; owner…</span></button>`
: ''
}
${!isCollab && typeof window._collabBucketsUpgradeDialog === 'function'
? `<button class="ctx-item" data-baction="collab-upgrade"><i class="ph ph-users-three text-sm"></i><span class="font-pixel text-[10px]">Make collaborative…</span></button>`
: ''
}
<div style="height:1px;background:var(--t-border);margin:2px 0"></div>
<button class="ctx-item ${!canLeft ? 'opacity-30 pointer-events-none' : ''}" data-baction="move-left"><i class="ph ph-arrow-left text-sm"></i><span class="font-pixel text-[10px]">Move left</span></button>
<button class="ctx-item ${!canRight ? 'opacity-30 pointer-events-none' : ''}" data-baction="move-right"><i class="ph ph-arrow-right text-sm"></i><span class="font-pixel text-[10px]">Move right</span></button>
@@ -56,12 +66,35 @@ async function showBucketCtx(x, y, bucket) {
document.getElementById('bctx-colors').classList.toggle('hidden');
return;
}
else if (a === 'collab-manage') {
hideBucketCtx();
if (typeof window._collabBucketsEditDialog === 'function') {
const changed = await window._collabBucketsEditDialog(bucket.collab_uid);
if (changed) await window._reloadAll();
}
return;
}
else if (a === 'collab-upgrade') {
hideBucketCtx();
if (typeof window._collabBucketsUpgradeDialog === 'function') {
const changed = await window._collabBucketsUpgradeDialog(bucket.id);
if (changed) await window._reloadAll();
}
return;
}
else if (a === 'rename') {
hideBucketCtx();
const name = await showDialog({ title: 'rename_bucket', input: true, placeholder: bucket.name });
if (!name || !name.trim()) return;
await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { name: name.trim() } });
await loadBuckets(); window._renderBoard();
// Collab buckets: push name change through the plugin (relay +
// mirror). The next poll pulls the new name into the local row.
if (bucket.collab_uid && typeof window._collabBucketsPatch === 'function') {
const res = await window._collabBucketsPatch(bucket.collab_uid, { name: name.trim() });
if (!res?.ok) { alert(res?.error || 'rename failed'); return; }
} else {
await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { name: name.trim() } });
}
await window._reloadAll();
}
else if (a === 'move-left' || a === 'move-right') {
hideBucketCtx();
@@ -76,12 +109,37 @@ async function showBucketCtx(x, y, bucket) {
hideBucketCtx();
const cards = state.cards.filter(c => c.bucket_id == bucket.id);
if (cards.length > 0 || localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
const ok = await showDialog({ title: 'delete_bucket', message: cards.length ? `Delete "${bucket.name}"? ${cards.length} card(s) will be kept.` : `Delete "${bucket.name}"?`, danger: true, okLabel: 'Delete' });
const msg = bucket.collab_uid
? (cards.length ? `Leave / delete "${bucket.name}"? ${cards.length} card(s) will be kept locally.` : `Leave / delete "${bucket.name}"?`)
: (cards.length ? `Delete "${bucket.name}"? ${cards.length} card(s) will be kept.` : `Delete "${bucket.name}"?`);
const ok = await showDialog({ title: 'delete_bucket', message: msg, danger: true, okLabel: bucket.collab_uid ? 'Leave / delete' : 'Delete' });
if (!ok) return;
}
if (state.activeBucket == bucket.id) state.activeBucket = 'all';
await api(`api/buckets/${bucket.id}`, { method: 'DELETE' });
await loadBuckets(); await window._reloadAll();
// Collab bucket: route through plugin so the relay is notified
// (owner = delete for everyone; member = just leave).
if (bucket.collab_uid && typeof window._collabBucketsDelete === 'function') {
const res = await window._collabBucketsDelete(bucket.collab_uid);
if (!res?.ok) { alert(res?.error || 'delete failed'); return; }
// Undo is intentionally NOT wired for collab deletes/leaves
// — restoring would require re-creating on the relay and
// re-attaching cards under a new uid. Too messy for MVP.
} else {
// Snapshot the row + affected card ids before deleting so
// the undo entry can restore them.
const snapshot = { name: bucket.name, color: bucket.color, position: bucket.position };
const affected = cards.map(c => c.id);
await api(`api/buckets/${bucket.id}`, { method: 'DELETE' });
pushUndo(`delete bucket "${bucket.name}"`, async () => {
const created = await api('api/buckets', { method: 'POST', body: { name: snapshot.name } });
if (created?.id) {
await api(`api/buckets/${created.id}`, { method: 'PATCH', body: { color: snapshot.color, position: snapshot.position } });
// Re-attach cards to the recreated bucket
await Promise.all(affected.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: created.id } })));
}
});
}
await window._reloadAll();
}
else if (a === 'delete-all') {
hideBucketCtx();
@@ -90,8 +148,13 @@ async function showBucketCtx(x, y, bucket) {
if (!ok) return;
for (const c of cards) await api(`api/cards/${c.id}`, { method: 'DELETE' });
if (state.activeBucket == bucket.id) state.activeBucket = 'all';
await api(`api/buckets/${bucket.id}`, { method: 'DELETE' });
await loadBuckets(); await window._reloadAll();
if (bucket.collab_uid && typeof window._collabBucketsDelete === 'function') {
const res = await window._collabBucketsDelete(bucket.collab_uid);
if (!res?.ok) { alert(res?.error || 'delete failed'); return; }
} else {
await api(`api/buckets/${bucket.id}`, { method: 'DELETE' });
}
await window._reloadAll();
}
}));
@@ -99,9 +162,14 @@ async function showBucketCtx(x, y, bucket) {
ctxMenu.querySelectorAll('[data-bctx-color]').forEach(btn => btn.addEventListener('click', async e => {
e.stopPropagation();
hideBucketCtx();
await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { color: btn.dataset.bctxColor } });
await loadBuckets();
window._renderBoard();
const newColor = btn.dataset.bctxColor;
if (bucket.collab_uid && typeof window._collabBucketsPatch === 'function') {
const res = await window._collabBucketsPatch(bucket.collab_uid, { color: newColor });
if (!res?.ok) { alert(res?.error || 'color change failed'); return; }
} else {
await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { color: newColor } });
}
await window._reloadAll();
}));
}
@@ -256,6 +324,14 @@ function renderBar() {
const addBtn = $('add-bucket-btn');
addBtn?.addEventListener('click', async () => {
// Plugins (e.g. collab-buckets) can override the default create
// dialog by exposing window._collabBucketsCreateDialog. It returns
// true when a bucket was created, in which case we reload.
if (typeof window._collabBucketsCreateDialog === 'function') {
const created = await window._collabBucketsCreateDialog();
if (created) { await loadBuckets(); window._renderBoard(); }
return;
}
const name = await showDialog({ title: 'new_bucket', input: true, placeholder: '> bucket name...' });
if (!name || !name.trim()) return;
await api('api/buckets', { method: 'POST', body: { name: name.trim() } });
+4
View File
@@ -200,6 +200,8 @@ export function openModal(card = null) {
currentLine = -1;
updateActiveLine();
inpTitle.focus();
// Plugins (collab-buckets) hook into this to heartbeat an edit lock
window.dispatchEvent(new CustomEvent('hyprcrd:editor-open', { detail: { card } }));
}
inpTitle.addEventListener('input', updatePreview);
@@ -354,6 +356,7 @@ body.addEventListener('keyup', updateActiveLine);
window.addEventListener('resize', () => { if (linesVisible) updateGutter(); });
export function closeModal({ animate = true } = {}) {
const closingId = state.editingId;
if (animate) startCloseMorph();
else clearMorphGhost();
hideLinkAc();
@@ -366,6 +369,7 @@ export function closeModal({ animate = true } = {}) {
state._returnToCalendar = false;
window._openCalendar?.();
}
window.dispatchEvent(new CustomEvent('hyprcrd:editor-close', { detail: { cardId: closingId } }));
}
function updatePreview() {
+358
View File
@@ -0,0 +1,358 @@
# collab-buckets plugin
Collaborative buckets shared between relay tenants. Each bucket has an owner
and a set of members; membership changes and metadata updates propagate
through the existing relay. Collab buckets are **first-class rows in the
normal bucket bar** — the plugin just owns the sync plus an unread
indicator (red dot + 3-cycle pulse) on any tab that has a fresh update.
**Phase 1 through 5 shipped.** Metadata + membership sync, bucket-bar
integration, full card sync (create / edit / delete, LWW conflict
resolution with local Conflicts-bucket stashing, encrypted round-trip
via the relay's decrypt-reencrypt hop), advisory per-card edit locks
with a 🔒 badge on cards another user is currently editing,
bucket-ownership transfer through a members & owner modal, and the
Phase 5 hardening pass: **"Make collaborative…"** upgrade path for
existing local buckets, **undo** for local bucket delete/leave,
**resilient create** via a local outbox that queues intents when the
relay is unreachable and retries on the next poll, and
**rate-limiting** on all collab endpoints. Remaining nice-to-haves
(position propagation, presence, read receipts, inline member picker,
owner approval flow) are in `TODO.md`.
---
## What it does
- **Create a collab bucket** from the normal `+ Bucket` button in the
bucket bar. The plugin intercepts the button click (via
`window._collabBucketsCreateDialog`) and shows a modal with an optional
"collaborative" checkbox. Unchecked → a regular local bucket, same as
before. Checked → reveals a member picker listing all other relay
tenants, and the submit path goes through `/api/collab-buckets` instead
of the core `/api/buckets`.
- **See other members' buckets** appear automatically in the bucket bar.
Every 30 seconds the plugin polls the relay for a full resync; any new
or updated bucket the current user is a member of is mirrored into the
main `buckets` table, so the core bar renders it alongside everything
else.
- **Collab tabs are visually distinguished**: the folder icon is swapped
for `ph-users-three`, there's a subtle left-edge accent stripe, and the
right-click menu's rename / color / delete actions route through the
plugin endpoints so changes reach the relay and other members.
- **Unread state**: brand-new buckets and ones whose `updated_at` exceeds
the local `last_seen_at` show a red dot top-right of the tab and pulse 3
times on the next render. Clicking the tab triggers `mark-seen` which
clears the dot/pulse immediately and persists to the plugin backend.
## Architecture
Two places store bucket state locally:
1. **Main `buckets` table** (core) — gains a `collab_uid` column so collab
buckets are indistinguishable from local buckets in the bucket bar,
bucket selects, card-to-bucket joins, etc. `collab_uid` is unique and
NULL for regular local buckets.
2. **Plugin `collab_buckets` table** — holds collab-only metadata
(`members_json`, `owner_username`, `updated_at`, `last_seen_at`) that
the main table doesn't need. On every poll the plugin keeps both
tables in sync.
```
┌───── Browser ─────┐ ┌──── Relay ────┐
│ │ │ │
│ bucket-bar (core) │ │ │
│ ├─ bucket rows │ POST /api/collab-buckets │ collab- │
│ │ incl. collab │ (create via + Bucket modal) │ buckets.php │
│ │ collab_uid │ ───────────────────────────▶ │ │
│ │ │ │ collab_ │
│ collab-buckets.js │ POST /api/collab-buckets │ buckets │
│ ▲ observer │ /poll (every 30s) │ │
│ │ MutationObs │ ◀─────────────────────────── │ collab_ │
│ │ + delegation │ full resync │ bucket_ │
│ ▼ │ │ members │
│ backend.php │ │ │
│ ├─ collab_buckets│ │ │
│ │ plugin table │ │ │
│ └─ upserts/ │ │ │
│ deletes rows │ │ │
│ in main │ │ │
│ buckets table │ │ │
└───────────────────┘ └───────────────┘
```
- **Relay is source of truth** for bucket metadata + membership. The two
local tables are mirrors that together add `last_seen_at` (unread state,
user-specific, never synced).
- **Full resync every 30 s.** Locally cached buckets that are no longer
returned by the relay for this user = we were kicked (or the bucket was
deleted); both tables drop them and any cards referencing them fall back
to unsorted via `ON DELETE SET NULL`.
- **LWW per bucket**: every patch bumps `updated_at` on the relay. The
local poll only re-saves rows whose `updated_at` actually changed,
otherwise it's a no-op and `last_seen_at` is untouched so unread state
survives unrelated polls.
## Files
| File | Purpose |
|---|---|
| `plugin.json` | Plugin manifest (auto-enabled unless listed in `data/plugins.json`) |
| `backend.php` | Plugin backend: `collab_buckets` plugin table, relay proxy, full-resync poll, mark-seen, + `collabUpsertMainBucket` / `collabDeleteMainBucket` helpers that keep the core `buckets` table in sync |
| `collab-buckets.js` | Frontend: polling loop, exposes `window._collabBucketsCreateDialog` for the core `+ Bucket` button, MutationObserver on `#bucket-bar` to decorate collab tabs with unread dot + pulse, delegated click handler for mark-seen, helpers for rename / color / delete that core's context menu calls for `collab_uid` buckets |
| `collab-buckets.css` | Collab-tab decoration (left accent stripe, unread dot, pulse animation) + the create / edit modal styling |
| `README.md` | This file |
| `TODO.md` | Deferred features + Phase 3 roadmap |
### Core-side touchpoints (not in this directory)
| File | What changes |
|---|---|
| `src/Database.php` | Migration adds `buckets.collab_uid TEXT` + a unique partial index |
| `public/index.php` | `api/buckets` GET returns `collab_uid` (via `SELECT *`). `PATCH` only allows `position` on collab buckets (name/color must go through the plugin so other members see them). `DELETE` refuses collab buckets with a 400 pointing to `/api/collab-buckets/{uid}` |
| `public/js/app.js` | Exposes `window.state = state` so the plugin's observer can read `state.buckets` to resolve `collab_uid` per tab |
| `public/js/modules/buckets.js` | `+ Bucket` click checks `window._collabBucketsCreateDialog` before falling back to the simple dialog. Rename / color / delete actions check `bucket.collab_uid` and route through `window._collabBucketsPatch` / `_collabBucketsDelete` when set |
Relay-side files (not in this directory, part of the main relay deployment):
| File | Purpose |
|---|---|
| `relay/inc/bootstrap.php` | Adds `collab_buckets` + `collab_bucket_members` tables (auto-migrated on first boot) |
| `relay/inc/routes/collab-buckets.php` | `POST/GET/PATCH/DELETE /api/collab-buckets[/{uid}]` |
| `relay/index.php` | `require`s the new route file after `chat.php` |
## API (local)
All routes live under `/api/collab-buckets` and are registered via
`PluginManager::instance()->onApiRoute()`.
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/collab-buckets` | List local mirror. Each row has `has_unread` computed from `last_seen_at` vs `updated_at`. |
| `POST` | `/api/collab-buckets` | `{name, color, members[]}` — generates UID, pushes to relay, mirrors local. The creator's own `last_seen_at = now` so the tile isn't unread for them. |
| `PATCH` | `/api/collab-buckets/{uid}` | `{name?, color?, members?}` — owner-only, proxied to relay. Local row updates on next poll. |
| `DELETE` | `/api/collab-buckets/{uid}` | Owner → delete; member → leave. Relay decides based on the authenticated caller. |
| `POST` | `/api/collab-buckets/poll` | Full resync. Drops stale local rows, upserts changed remote rows, preserves `last_seen_at`. |
| `POST` | `/api/collab-buckets/mark-seen` | `{uid}` — sets `last_seen_at = now` so the dot / pulse clear. |
| `GET` | `/api/collab-buckets/relay-tenants` | Proxy to relay `/api/tenants` for the member-picker. |
## API (relay)
Exact endpoints the local plugin forwards to. Authentication: the standard
`X-Relay-User` / `X-Relay-Key` client-API headers that the existing chat and
messages routes also use.
### Buckets
| Method | Path | Behaviour |
|---|---|---|
| `POST` | `/api/collab-buckets` | `{uid, name, color, members: [usernames]}`. Unknown usernames are silently dropped; the owner is always added to the member list regardless of whether they included themselves. |
| `GET` | `/api/collab-buckets` | Returns all buckets where the caller is a member (owner or otherwise). Each row carries its full member list as `members: [usernames]`. |
| `PATCH` | `/api/collab-buckets/{uid}` | Owner-only. Member-list replacement is total — pass the full desired list. |
| `DELETE` | `/api/collab-buckets/{uid}` | Owner → `DELETE` of bucket + memberships (with `action: deleted`). Member → just that one membership row (with `action: left`); the bucket's `updated_at` is bumped so the owner sees the change on next poll. |
### Cards (Phase 3)
| Method | Path | Behaviour |
|---|---|---|
| `POST` | `/api/collab-cards` | `{bucket_uid, card_uid, payload}` — upsert. `payload` is a `HYPRCRD-RELAY-V1` envelope encrypted with the sender's client secret. The relay decrypts it with the sender's secret (via `masterDecrypt` of their stored `secret_encrypted`), re-encrypts with the inter-tenant key, stores. Server generates `updated_at` so all clients agree on the authoritative LWW timestamp. Membership-guarded. |
| `GET` | `/api/collab-cards?since=<ts>` | Pull cards updated since `<ts>` (ISO string from a previous response's `max_updated_at`) across all buckets the caller is a member of. Each row is re-encrypted under the caller's client secret on the way out. Tombstoned rows return with `deleted: true` and no payload. Hard-capped to 1000 cards per response. |
| `DELETE` | `/api/collab-cards/{bucket_uid}/{card_uid}` | Mark tombstone (not hard-delete) so late pollers still observe the deletion within the 7-day TTL. Membership-guarded. |
## Schema (local)
```sql
CREATE TABLE collab_buckets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT UNIQUE NOT NULL, -- 16-hex string generated by this client
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#38bdf8',
owner_username TEXT NOT NULL,
members_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at TEXT -- NULL = never opened → unread
);
-- Phase 3: per-card sync state. Attached to the core `cards` table via
-- card_id FK. `last_push_at` mirrors the updated_at we last sent to the
-- relay; the push phase picks up cards where c.updated_at > last_push_at.
-- `last_remote_at` mirrors the updated_at we last applied from the relay.
CREATE TABLE collab_card_state (
card_id INTEGER PRIMARY KEY,
relay_uid TEXT NOT NULL,
bucket_uid TEXT NOT NULL,
last_push_at TEXT,
last_remote_at TEXT,
FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
);
-- Phase 3: deletion outbox. Populated by a BEFORE DELETE trigger on the
-- cards table; drained in the push phase of the next poll.
CREATE TABLE collab_tombstones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
relay_uid TEXT NOT NULL,
bucket_uid TEXT NOT NULL,
queued_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
`members_json` is a JSON array of usernames — flat and cheap to render.
Because the relay is the source of truth, we don't need to normalise
members into a separate local table.
The plugin also creates a BEFORE-DELETE trigger on `cards` that inserts a
tombstone row whenever a card in a collab bucket is deleted — unless the
suppress temp table `collab_suppress_outbox` has a row, which the plugin
sets during the poll's apply phase so remotely-triggered deletes don't
re-enqueue.
## Schema (relay)
```sql
CREATE TABLE collab_buckets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT UNIQUE NOT NULL,
owner_id INTEGER NOT NULL,
owner_username TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#38bdf8',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (owner_id) REFERENCES tenants(id) ON DELETE CASCADE
);
CREATE TABLE collab_bucket_members (
bucket_uid TEXT NOT NULL,
tenant_id INTEGER NOT NULL,
username TEXT NOT NULL,
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (bucket_uid, tenant_id),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
);
-- Phase 3: the actual synced card content. `payload` is the re-encrypted
-- envelope stored at rest under the inter-tenant key. `deleted = 1`
-- marks a tombstone; bootstrap.php prunes them after 7 days.
CREATE TABLE collab_cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bucket_uid TEXT NOT NULL,
card_uid TEXT NOT NULL,
author_username TEXT NOT NULL,
payload TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted INTEGER NOT NULL DEFAULT 0,
UNIQUE (bucket_uid, card_uid)
);
```
Owner deletion cascades via `ON DELETE CASCADE` on the `owner_id` FK.
Member rows are pruned explicitly on leave (or cascade when a tenant is
deleted outright). `collab_cards` rows belonging to a deleted bucket are
orphaned but unreachable — the GET query joins `collab_bucket_members`
and returns nothing for them, so they're effectively gone. Future hardening
could add a cascade trigger.
## Card sync flow (Phase 3)
Every poll does three things in strict order inside one request:
1. **Drain tombstones.** The `collab_tombstones` outbox table is populated
by a BEFORE DELETE trigger on `cards` — whenever a user deletes a card
that sits in a collab bucket, the trigger inserts a row `{relay_uid,
bucket_uid}` into the outbox. The poll pushes each row to
`DELETE /api/collab-cards/{bucket}/{card}` and drops the outbox entry
on success. Failed pushes stay queued and retry next poll.
2. **Push dirty cards.** One query finds every card in a collab bucket
whose `updated_at` is greater than `collab_card_state.last_push_at`
(or never-pushed). Each is encrypted with the local `relay_key` and
POSTed to `/api/collab-cards`. On success, `last_push_at` is stamped
with the server's returned `updated_at` so the next poll skips it.
Cards without a `relay_uid` get one assigned (16 hex chars) and the
core `cards.relay_uid` column is updated so the card has a stable
cross-tenant identity.
3. **Pull deltas.** `GET /api/collab-cards?since=<stored>` fetches every
card updated or tombstoned since the stored marker (core `settings`
row `collab_cards_last_remote_at`). For each incoming card:
- If `deleted: true` → look up the local card by `relay_uid` and
`DELETE` it. The BEFORE DELETE trigger is suppressed via the
`collab_suppress_outbox` temp-table flag so it doesn't re-enqueue
the tombstone back into the outbox.
- Otherwise, decrypt the payload, find the local card by `relay_uid`,
and apply LWW (`remote.updated_at > local.updated_at` → overwrite).
New cards are INSERTed with `bucket_id` resolved via
`buckets.collab_uid`. In both cases `collab_card_state.last_push_at`
and `last_remote_at` are set to the remote's `updated_at` so the
next push phase doesn't re-send what was just received.
Conflict resolution is strict LWW on `updated_at`. If two users save the
same card within the same second, the second writer wins; the first
writer's changes are silently overwritten on their next pull. Phase 4
adds optional edit-locks to surface the conflict before it happens.
## Unread / pulse state
`has_unread` is computed by the plugin backend on every read of
`/api/collab-buckets`:
```
has_unread = (last_seen_at IS NULL) OR (updated_at > last_seen_at)
```
- **Brand-new bucket** → poll inserts the plugin row with `last_seen_at =
NULL` → unread.
- **Metadata / member change** → relay bumps `updated_at` → poll upserts →
`updated_at > last_seen_at` → unread.
- **Click tab** → `mark-seen` sets `last_seen_at = now` → unread clears on
the next render.
The plugin frontend watches `#bucket-bar` via `MutationObserver`. After
every core re-render, it walks the tabs, looks up each bucket in
`state.buckets`, and applies `.bucket-tab-collab` plus (if unread) the
`.pulsing` class and an injected `.collab-tab-dot` element. The pulse
animation is CSS-driven (`collab-bucket-pulse 1.2s ease-in-out 3`), and
the JS strips `.pulsing` after 3.7 s so subsequent re-renders don't
retrigger it mid-cycle.
## Theming
All chrome hooks into the standard design tokens:
- `--ui-radius-button` — tile and button rounding
- `--ui-radius-modal` — create/edit modal
- `--ui-radius-input` — form inputs
- `--ui-shadow-modal` — modal drop shadow
- `--ui-blur-overlay` — modal backdrop blur
- `--collab-tile-color` (per-tile) — bucket color, mixed into border, text,
hover background, and pulse ring
So a collab tile automatically inherits the currently selected theme and the
user's design-token choices without any plugin-side theming code.
## Why this shape
- **First-class bucket rows, not a separate UI.** Collab buckets show up
exactly where users already look for buckets. No extra header tiles, no
second "collab+" button — the existing `+ Bucket` workflow just gains a
"collaborative" checkbox. The mental model stays "this is a bucket", the
plugin only layers sync on top.
- **Polling, not push.** No long-lived connection, no WebSocket, no daemon.
30-second cadence is perfectly fine for the "someone added a card to our
shared bucket" latency envelope and reuses the existing relay HTTP path.
- **Relay as source of truth.** The local mirror is almost stateless — only
`last_seen_at` is user-specific and never leaves the device. Wipes and
re-syncs are trivial.
- **Owner-only writes.** Simpler than ACLs. Owners who want to hand off
control can delete-and-recreate, or we add a transfer endpoint in Phase 4.
- **Soft coupling to core.** Core exposes two tiny hooks
(`window._collabBucketsCreateDialog`, `window.state`) plus a single
column on the buckets table. Everything else — dialog, decoration,
polling, mark-seen — lives in the plugin directory and goes away when
the plugin is disabled.
## Disabling
Like every other plugin, add `"collab-buckets"` to the `disabled` array in
`data/plugins.json`. The local table is left in place (not dropped) so
re-enabling doesn't lose unread state.
+151
View File
@@ -0,0 +1,151 @@
# collab-buckets plugin — roadmap
> Phase 1, 2 and 3 are shipped: metadata + membership sync, bucket-bar
> integration, and full card sync with encrypted round-trip and LWW
> conflict resolution. Phase 4 is polish — edit locks, ownership
> transfer, member presence, etc. Phase 5 is hardening.
## Phase 3 — card sync (DONE ✓)
- [x] **`collab_card_state` table (local)** — maps a local `cards.id` to
a collab `bucket_uid` + `relay_uid` plus `last_push_at` and
`last_remote_at` for LWW tracking. Poll phase 1 finds dirty cards
via `c.updated_at > cs.last_push_at`.
- [x] **Save propagation without editor coupling** — the poll's push
phase is query-based (no trigger, no hook). Whenever the user
saves a card and the core bumps `cards.updated_at`, the next
poll within 30s finds it dirty and pushes.
- [x] **Relay `collab_cards` table + routes** — `POST /api/collab-cards`
decrypts-reencrypts via inter-tenant key; `GET ?since=<ts>`
re-encrypts per recipient; `DELETE /{bucket}/{card}` marks a
tombstone with a 7-day TTL (cleaned up by bootstrap.php).
- [x] **Poll extension** — `collabSyncCards()` runs three phases
(tombstones → push → pull) with a temp-table suppress flag so the
delete trigger doesn't re-enqueue during apply.
- [x] **Card visibility** — collab buckets already take part in the core
bucket-bar's click-to-filter flow, so cards synced into them
automatically show up on the board.
- [x] **Card deletion propagation** — BEFORE DELETE trigger writes to
`collab_tombstones`. Drained in the next poll as a DELETE.
Suppressed during the apply phase so remote deletes don't loop
back as local tombstones.
- [x] **Conflict losers as "(conflict from X)" duplicates** — when the
apply phase is about to LWW-clobber a local card that had edits
since our last push (`local.updated_at > collab_card_state.last_push_at`),
we now INSERT a copy of the local version into a local-only
"Conflicts" bucket with a `(conflict from <author>) ...` title
prefix before the overwrite. The poll response carries a
`conflicts` count; the frontend pops a toast pointing the user at
the Conflicts bucket when it's non-zero.
- [x] **Moving a card between collab buckets (or out to a local one)**
— the push phase now has a pre-pass that finds cards where
`collab_card_state.bucket_uid` no longer matches the card's
current bucket collab_uid. For each such card it sends a
tombstone to the old bucket. If the card is still in a (different)
collab bucket, state is rewritten with the new `bucket_uid` and
`last_push_at = NULL` so the main push loop re-uploads under the
new bucket. If the card left collab entirely, the state row is
removed.
## Phase 4 — polish
- [x] **Per-card edit lock** — when the editor opens on a collab card the
plugin heartbeats `POST /api/collab-locks {bucket_uid, card_uid}`
every 30 s with a 60 s TTL on the relay. The lock list is folded
into the regular `/api/collab-buckets/poll` response so no extra
round-trips are needed. Foreign locks show a 🔒 badge on the
affected board card + a subtle inset outline. Releases on editor
close and best-effort on `beforeunload` via `fetch(..., keepalive)`.
Advisory only — users can still force-edit; LWW + conflict stashing
remain the final arbiter. Core `editor.js` dispatches
`hyprcrd:editor-open` / `hyprcrd:editor-close` `CustomEvent`s so
the plugin hooks in without monkey-patching.
- [x] **Ownership transfer** — `PATCH /api/collab-buckets/{uid}`
accepts `{owner: <username>}`. The relay validates that the new
owner is currently a member, swaps `owner_id` + `owner_username`,
and forces both the caller and the new owner to stay in the member
list on the same request. Surfaced in the UI via a new "Members &
owner…" entry in the bucket context menu that opens a plugin
dialog with a member picker + owner dropdown (only shown when the
caller is the current owner; other members see a read-only view).
- [ ] **Position propagation (optional)** — reorder is purely local right
now. If multiple members expect the same order, we'd need to sync
`position` via the relay, but it's probably fine to leave this as a
per-user preference.
- [ ] **Member presence indicator** — show a small dot next to usernames in
the members panel when they were seen by the relay within the last
minute (reuse chat's heartbeat or add a collab-specific ping).
- [ ] **"Recently active members"** — show avatars (first letter) of
members who touched a card in the last hour, on the collab bucket tab.
- [ ] **Read receipts** — "3 / 5 seen" for the latest update on each
collab bucket. Requires the relay to store per-member `last_seen_at`
— bigger schema change.
## Phase 5 — hardening & UX
- [x] **Migration of existing local buckets** — the bucket context menu
on a non-collab bucket shows a "Make collaborative…" entry when
the plugin is loaded. It opens a plugin dialog where the owner
picks members, then `POST /api/collab-buckets/upgrade
{bucket_id, members}` generates a new `uid`, pushes it to the
relay, and writes `collab_uid` onto the same local `buckets` row
— no card shuffling, no id rewrite. The first poll after the
upgrade sees every card in the bucket as dirty (no
`collab_card_state`) and pushes them.
- [x] **Undo for destructive actions** — local bucket delete/leave now
hooks into the existing `pushUndo` system: the undo entry
recreates the bucket via `POST /api/buckets`, restores the
original color+position with PATCH, and re-attaches every card
that pointed at the old row. Collab bucket delete/leave is
intentionally *not* wired — restoring would need to recreate on
the relay under a new uid and re-attach cards there too, which
is messy and less valuable since collab deletes are usually
deliberate.
- [x] **Resilient create via outbox** — a new
`collab_bucket_outbox(id, bucket_id, members_json, queued_at)`
table. The create/upgrade flow always creates the local `buckets`
row first and then tries `collabAttemptUpgrade()`. On relay
failure the intent is queued in the outbox and the next poll
retries. The response includes `queued: true` so the frontend can
show a "saved locally, will sync later" info toast; a follow-up
poll that successfully drains the outbox fires a second toast.
Stale outbox entries for deleted buckets are dropped automatically.
- [x] **Rate limiting on collab endpoints** — every collab route now
funnels through the existing relay `isRateLimited()` helper with
generous caps (120/min for GET, 240/min for POST). Over-limit
requests get a clean `429 rate limited` response.
- [ ] **Inline member picker in the bucket context menu** — currently
needs the manage-modal for any change. A quick `+user` / `-user`
flyout would help for tiny edits.
- [ ] **Owner-side member approval flow** — owners can still add any
relay user unilaterally. A pending-invite state with accept/reject
on the invitee side would be nicer for consent but requires a new
relay table + plumbing.
## Known limitations (current)
- **LWW conflicts still clobber on the remote side.** When two users save
the same card within a few seconds, the later writer wins. The earlier
writer gets their local version stashed into a `Conflicts` bucket and
a toast notification — nothing is lost — but their version is no
longer the canonical one anyone else sees. Phase 4 edit-locks prevent
the race in the first place.
- **Up to ~30 s sync latency.** Changes propagate on the next poll, not
instantly. Acceptable for MVP; a "push now" hook after save would cut
latency to ~1 s.
- **Full resync on every poll.** Cheap for small member counts but grows
O(N_buckets × N_members) on the relay side. A `since=updated_at` filter
would help — adding it is safe because the poll also has to handle full
removals (kicked from bucket), and we can fold the deletion list into the
response.
- **Bucket metadata is plaintext on the relay.** Name + color + member list
are visible to a relay admin with DB access. Card content (Phase 3) will
be encrypted end-to-end via the existing decrypt-reencrypt hop; bucket
metadata could follow the same pattern but adds complexity for minimal
gain and makes member picking harder.
- **No offline support.** Creating a bucket while the relay is unreachable
fails hard. Phase 5 outbox pattern would fix this.
- **Race on UID generation.** The client generates `bin2hex(random_bytes(8))`
= 64 bits of entropy. Collisions between independent clients are
astronomically unlikely, but the relay rejects them with 409 if they ever
happen. No retry on the client side yet.
+876
View File
@@ -0,0 +1,876 @@
<?php
/**
* Collab Buckets Plugin: backend.
*
* Local mirror of the relay's collab_buckets state. Provides a thin proxy
* over the relay's /api/collab-buckets routes plus a poll endpoint that
* does a full resync (so deletions / removed memberships propagate).
*
* Routes:
* GET /api/collab-buckets list local buckets
* POST /api/collab-buckets {name, color, members[]} -> create on relay + mirror local
* PATCH /api/collab-buckets/{uid} {name?, color?, members?} -> push to relay
* DELETE /api/collab-buckets/{uid} owner: delete; member: leave
* POST /api/collab-buckets/poll full re-sync from relay
* POST /api/collab-buckets/mark-seen {uid} -> reset dot/pulse state
* GET /api/collab-buckets/relay-tenants proxy to relay /api/tenants for the member-picker
*
* Phase 1+2: bucket metadata + membership only. Card sync lands in Phase 3.
*/
(function () {
$db = Database::connect();
$db->exec("
CREATE TABLE IF NOT EXISTS collab_buckets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#38bdf8',
owner_username TEXT NOT NULL,
members_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at TEXT
)
");
$db->exec("CREATE INDEX IF NOT EXISTS idx_collab_buckets_uid ON collab_buckets(uid)");
// ── Phase 3 schema: card sync tracking ───────────────────────
// Per-card state: which relay uid and bucket uid the card maps to, plus
// the last time we pushed it and the last remote updated_at we applied.
// `last_push_at` is compared against `cards.updated_at` to find dirty
// cards that need a push. On apply, `last_push_at` is bumped to the
// remote's updated_at so we don't re-push what we just received.
$db->exec("
CREATE TABLE IF NOT EXISTS collab_card_state (
card_id INTEGER PRIMARY KEY,
relay_uid TEXT NOT NULL,
bucket_uid TEXT NOT NULL,
last_push_at TEXT,
last_remote_at TEXT,
FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
)
");
$db->exec("CREATE INDEX IF NOT EXISTS idx_collab_card_state_uid ON collab_card_state(relay_uid)");
// Tombstone outbox: populated by a BEFORE DELETE trigger on the cards
// table. The poll's push phase drains it and sends each entry as a
// DELETE to the relay.
//
// The trigger fires unconditionally on any collab-card delete, including
// deletions caused by *applying a remote tombstone*. That would loop the
// tombstone back to the relay pointlessly. To break the loop without
// needing a temp-table suppress flag (SQLite forbids triggers on
// permanent tables from referencing `temp.*` objects), the apply phase
// snapshots MAX(id) on `collab_tombstones` before it starts and deletes
// every row with id > that marker after it finishes — i.e. everything
// that the apply phase itself caused.
$db->exec("
CREATE TABLE IF NOT EXISTS collab_tombstones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
relay_uid TEXT NOT NULL,
bucket_uid TEXT NOT NULL,
queued_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
");
// Phase 5: create-outbox for buckets. When the user creates (or
// upgrades) a collab bucket but the relay is unreachable, the local
// row is created anyway and an entry is queued here. The poll drains
// it on every tick by retrying the upgrade.
$db->exec("
CREATE TABLE IF NOT EXISTS collab_bucket_outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bucket_id INTEGER NOT NULL,
members_json TEXT NOT NULL DEFAULT '[]',
queued_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
");
$db->exec("
CREATE TRIGGER IF NOT EXISTS collab_card_delete_tombstone
BEFORE DELETE ON cards
WHEN OLD.bucket_id IS NOT NULL
AND OLD.relay_uid IS NOT NULL
BEGIN
INSERT INTO collab_tombstones (relay_uid, bucket_uid)
SELECT OLD.relay_uid, b.collab_uid
FROM buckets b
WHERE b.id = OLD.bucket_id AND b.collab_uid IS NOT NULL;
END
");
})();
/** Encrypt a JSON payload with the local relay_key using the same envelope
* format the rest of hyprcrd uses (HYPRCRD-RELAY-V1). */
function collabEncryptPayload(array $fields): ?string {
$key = collabRelaySettings()['key'] ?? '';
if ($key === '') return null;
$encKey = substr(hash('sha256', $key), 0, 32);
$plain = json_encode($fields);
$iv = random_bytes(16);
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
if ($cipher === false) return null;
return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher);
}
/** Decrypt the inverse. Returns an associative array, or null on failure. */
function collabDecryptPayload(string $envelope): ?array {
if (!str_starts_with($envelope, 'HYPRCRD-RELAY-V1')) return null;
$b64 = trim(substr($envelope, strlen('HYPRCRD-RELAY-V1') + 1));
$raw = base64_decode($b64);
if (!$raw || strlen($raw) < 17) return null;
$key = collabRelaySettings()['key'] ?? '';
if ($key === '') return null;
$encKey = substr(hash('sha256', $key), 0, 32);
$iv = substr($raw, 0, 16);
$cipher = substr($raw, 16);
$plain = openssl_decrypt($cipher, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
if ($plain === false) return null;
$data = json_decode($plain, true);
return is_array($data) ? $data : null;
}
function collabJson($data, int $code = 200): void {
http_response_code($code);
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
function collabRelaySettings(): array {
$db = Database::connect();
return [
'url' => $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn() ?: '',
'user' => $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn() ?: '',
'key' => $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn() ?: '',
];
}
function collabRelayCall(string $path, string $method = 'GET', $body = null): array {
$r = collabRelaySettings();
if (!$r['url'] || !$r['user'] || !$r['key']) {
return ['ok' => false, 'error' => 'relay not configured (set URL, username, and secret in settings)'];
}
$url = rtrim($r['url'], '/') . $path;
$ch = curl_init($url);
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Relay-User: ' . $r['user'],
'X-Relay-Key: ' . $r['key'],
],
CURLOPT_TIMEOUT => 10,
];
if ($body !== null) $opts[CURLOPT_POSTFIELDS] = json_encode($body);
curl_setopt_array($ch, $opts);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
$decoded = json_decode($response ?: '', true);
return [
'ok' => false,
'status' => $status,
'error' => is_array($decoded) && !empty($decoded['error']) ? $decoded['error'] : "HTTP $status",
'url' => $url,
];
}
$decoded = json_decode($response ?: '', true);
return is_array($decoded) ? $decoded : ['ok' => false, 'error' => 'invalid relay response'];
}
/** Serialize a local bucket row for the frontend. */
function collabShapeLocalBucket(array $r): array {
$members = json_decode($r['members_json'] ?? '[]', true) ?: [];
return [
'uid' => $r['uid'],
'name' => $r['name'],
'color' => $r['color'],
'owner_username' => $r['owner_username'],
'members' => $members,
'created_at' => $r['created_at'],
'updated_at' => $r['updated_at'],
'last_seen_at' => $r['last_seen_at'],
// A bucket has unread state if it was never opened OR if remote
// updated it after the last time we opened it.
'has_unread' => $r['last_seen_at'] === null || $r['updated_at'] > $r['last_seen_at'],
];
}
/** Upsert the corresponding row in the main `buckets` table so the collab
* bucket shows up in the standard bucket bar. Returns the main bucket id. */
function collabUpsertMainBucket(string $uid, string $name, string $color): int {
$db = Database::connect();
$stmt = $db->prepare('SELECT id FROM buckets WHERE collab_uid = :u');
$stmt->execute(['u' => $uid]);
$existing = $stmt->fetchColumn();
if ($existing) {
$db->prepare('UPDATE buckets SET name = :n, color = :c WHERE id = :id')
->execute(['n' => $name, 'c' => $color, 'id' => $existing]);
return (int)$existing;
}
// Put new collab buckets at the end of the bar
$maxPos = (int)$db->query('SELECT COALESCE(MAX(position), 0) FROM buckets')->fetchColumn();
$db->prepare('INSERT INTO buckets (name, color, collab_uid, position) VALUES (:n, :c, :u, :p)')
->execute(['n' => $name, 'c' => $color, 'u' => $uid, 'p' => $maxPos + 1]);
return (int)$db->lastInsertId();
}
/** Delete the main `buckets` row for a collab bucket (if any) and unlink its
* cards. Uses the same ON DELETE SET NULL semantics as the regular delete path. */
function collabDeleteMainBucket(string $uid): void {
$db = Database::connect();
$stmt = $db->prepare('SELECT id FROM buckets WHERE collab_uid = :u');
$stmt->execute(['u' => $uid]);
$bid = $stmt->fetchColumn();
if (!$bid) return;
$db->prepare('UPDATE cards SET bucket_id = NULL WHERE bucket_id = :id')->execute(['id' => $bid]);
$db->prepare('DELETE FROM buckets WHERE id = :id')->execute(['id' => $bid]);
}
/**
* Shared upgrade path: given a local buckets row, generate a fresh uid,
* push the create to the relay, then (on success) rewrite the local
* collab_uid and install the plugin mirror row. Used by both the
* "upgrade existing bucket" route AND the new outbox drain that retries
* failed creates.
*
* Returns an array with:
* ok=true + uid on success
* ok=false + error on relay failure — the caller decides
* whether to queue a retry in the outbox
* ok=true + already=true if the bucket already has a collab_uid
*/
function collabAttemptUpgrade(int $bucketId, array $members): array {
$db = Database::connect();
$stmt = $db->prepare('SELECT id, name, color, collab_uid FROM buckets WHERE id = :id');
$stmt->execute(['id' => $bucketId]);
$bucket = $stmt->fetch();
if (!$bucket) return ['ok' => false, 'error' => 'bucket not found'];
if (!empty($bucket['collab_uid'])) {
return ['ok' => true, 'already' => true, 'uid' => $bucket['collab_uid']];
}
$uid = bin2hex(random_bytes(8));
$res = collabRelayCall('/api/collab-buckets', 'POST', [
'uid' => $uid,
'name' => $bucket['name'],
'color' => $bucket['color'],
'members' => $members,
]);
if (!($res['ok'] ?? false)) {
return ['ok' => false, 'error' => $res['error'] ?? 'relay create failed'];
}
$db->prepare('UPDATE buckets SET collab_uid = :u WHERE id = :id')
->execute(['u' => $uid, 'id' => $bucketId]);
$now = date('Y-m-d H:i:s');
$self = collabRelaySettings()['user'];
$resolvedMembers = $res['members'] ?? $members;
$db->prepare('
INSERT INTO collab_buckets
(uid, name, color, owner_username, members_json, created_at, updated_at, last_seen_at)
VALUES
(:uid, :n, :c, :ou, :m, :ca, :ua, :ls)
ON CONFLICT(uid) DO UPDATE SET
name = excluded.name, color = excluded.color,
owner_username = excluded.owner_username,
members_json = excluded.members_json,
updated_at = excluded.updated_at, last_seen_at = excluded.last_seen_at
')->execute([
'uid' => $uid, 'n' => $bucket['name'], 'c' => $bucket['color'],
'ou' => $self,
'm' => json_encode($resolvedMembers),
'ca' => $now, 'ua' => $now,
'ls' => $now,
]);
return ['ok' => true, 'uid' => $uid];
}
/** Find or create a local-only "Conflicts" bucket where LWW losers get
* stashed. Never gets a collab_uid so it doesn't sync anywhere. */
function collabFindOrCreateConflictsBucket(): int {
$db = Database::connect();
$stmt = $db->prepare("SELECT id FROM buckets WHERE name = 'Conflicts' AND collab_uid IS NULL");
$stmt->execute();
$id = $stmt->fetchColumn();
if ($id) return (int)$id;
$maxPos = (int)$db->query('SELECT COALESCE(MAX(position), 0) FROM buckets')->fetchColumn();
$db->prepare("INSERT INTO buckets (name, color, position) VALUES ('Conflicts', '#fb7185', :p)")
->execute(['p' => $maxPos + 1]);
return (int)$db->lastInsertId();
}
/** Duplicate a card into the Conflicts bucket with a title marker, so the
* local user can see what they lost before the remote overwrite applied.
* Called from the apply phase when LWW is about to clobber unsynced local
* edits (local.updated_at > last_push_at && remote.updated_at > local). */
function collabStashConflict(PDO $db, array $local, string $remoteAuthor): void {
$bid = collabFindOrCreateConflictsBucket();
$origTitle = (string)($local['title'] ?? '');
$newTitle = '(conflict' . ($remoteAuthor !== '' ? ' from ' . $remoteAuthor : '') . ') ' . $origTitle;
$db->prepare("
INSERT INTO cards (title, body, color, tags, position, due_date, bucket_id, created_at, updated_at)
VALUES (:t, :b, :c, :tg, :p, :d, :bid, datetime('now'), datetime('now'))
")->execute([
't' => substr($newTitle, 0, 400),
'b' => $local['body'] ?? '',
'c' => $local['color'] ?? '#fb7185',
'tg' => $local['tags'] ?? '',
'p' => (int)($local['position'] ?? 0),
'd' => $local['due_date'] ?? null,
'bid' => $bid,
]);
}
/** Build the card payload we send to the relay. Fields that don't matter
* cross-user (parent_id for groups, caldav_*, the autoinc id) are omitted. */
function collabBuildCardPayload(array $card): array {
return [
'title' => $card['title'] ?? '',
'body' => $card['body'] ?? '',
'color' => $card['color'] ?? '#38bdf8',
'tags' => $card['tags'] ?? '',
'position' => (int)($card['position'] ?? 0),
'due_date' => $card['due_date'] ?? null,
'updated_at' => $card['updated_at'] ?? null,
];
}
/**
* Phase 3 sync: push local changes + pull remote deltas for all collab
* buckets. Returns counters for the response body.
*
* Order matters:
* 1. Drain tombstones — pushes delete-your-card notifications before we
* pull, otherwise we might immediately re-apply a remote version of
* the just-deleted card.
* 2. Push dirty cards — `cards.updated_at > collab_card_state.last_push_at`
* means the user edited since we last pushed. Encrypt, POST, stamp
* last_push_at so the next poll skips.
* 3. Pull deltas — since the highest `last_remote_at` we have, apply LWW
* to local rows. The suppress temp-table flag keeps the BEFORE DELETE
* trigger from re-queueing remotely-triggered deletes.
*/
function collabSyncCards(PDO $db): array {
$relay = collabRelaySettings();
$stats = ['pushed' => 0, 'pulled' => 0, 'deleted_local' => 0, 'tombstones_sent' => 0, 'conflicts' => 0, 'moved' => 0];
if (!$relay['url'] || !$relay['user'] || !$relay['key']) return $stats;
// ── 1. Drain tombstones ────────────────────────────────
$tombRows = $db->query('SELECT id, relay_uid, bucket_uid FROM collab_tombstones ORDER BY id ASC')->fetchAll();
foreach ($tombRows as $t) {
$res = collabRelayCall(
'/api/collab-cards/' . urlencode($t['bucket_uid']) . '/' . urlencode($t['relay_uid']),
'DELETE'
);
if (($res['ok'] ?? false)) {
$db->prepare('DELETE FROM collab_tombstones WHERE id = :id')->execute(['id' => $t['id']]);
$stats['tombstones_sent']++;
}
// If a tombstone push fails it stays queued and gets retried next poll.
}
// ── 1b. Detect moved cards ─────────────────────────────
// A card previously tracked in collab_card_state under bucket_uid X can
// now be in (a) no bucket, (b) a local bucket without collab_uid, or
// (c) a different collab bucket. In all three cases we need to push a
// tombstone to the original bucket so other members stop seeing the
// stale copy. If the card is still a collab card in a new bucket, the
// normal push phase below picks it up and pushes under the new uid.
$movedRows = $db->query("
SELECT cs.card_id, cs.relay_uid, cs.bucket_uid AS old_bucket_uid,
c.id IS NULL AS card_missing,
b.collab_uid AS new_bucket_uid
FROM collab_card_state cs
LEFT JOIN cards c ON c.id = cs.card_id
LEFT JOIN buckets b ON b.id = c.bucket_id
WHERE c.id IS NULL
OR b.collab_uid IS NULL
OR b.collab_uid != cs.bucket_uid
")->fetchAll();
foreach ($movedRows as $mv) {
// Tombstone on the OLD bucket — members of that bucket should stop
// seeing this card regardless of where it went.
$res = collabRelayCall(
'/api/collab-cards/' . urlencode($mv['old_bucket_uid']) . '/' . urlencode($mv['relay_uid']),
'DELETE'
);
if (!($res['ok'] ?? false)) continue; // retry next poll
$stats['moved']++;
if ($mv['card_missing'] || empty($mv['new_bucket_uid'])) {
// Card is gone or moved out of collab entirely — forget about it
$db->prepare('DELETE FROM collab_card_state WHERE card_id = :id')
->execute(['id' => $mv['card_id']]);
} else {
// Card moved to a different collab bucket. Rewrite state so the
// push phase below picks it up under the new bucket_uid and also
// resets last_push_at so the push actually fires.
$db->prepare("
UPDATE collab_card_state
SET bucket_uid = :b, last_push_at = NULL
WHERE card_id = :id
")->execute(['b' => $mv['new_bucket_uid'], 'id' => $mv['card_id']]);
}
}
// ── 2. Push dirty cards ────────────────────────────────
// Find every card in a collab bucket whose updated_at is newer than the
// last time we pushed it (or was never pushed).
$dirty = $db->query("
SELECT c.id, c.title, c.body, c.color, c.tags, c.position, c.due_date,
c.updated_at, c.relay_uid, b.collab_uid AS bucket_uid,
cs.last_push_at
FROM cards c
INNER JOIN buckets b ON b.id = c.bucket_id AND b.collab_uid IS NOT NULL
LEFT JOIN collab_card_state cs ON cs.card_id = c.id
WHERE cs.last_push_at IS NULL OR c.updated_at > cs.last_push_at
")->fetchAll();
$assignUid = $db->prepare('UPDATE cards SET relay_uid = :u WHERE id = :id');
$stateUpsert = $db->prepare("
INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at)
VALUES (:cid, :ru, :bu, :push, COALESCE((SELECT last_remote_at FROM collab_card_state WHERE card_id = :cid2), NULL))
ON CONFLICT(card_id) DO UPDATE SET
relay_uid = excluded.relay_uid,
bucket_uid = excluded.bucket_uid,
last_push_at = excluded.last_push_at
");
foreach ($dirty as $card) {
// Ensure the card has a relay_uid (cross-tenant identity)
$relayUid = $card['relay_uid'];
if (!$relayUid) {
$relayUid = bin2hex(random_bytes(8));
$assignUid->execute(['u' => $relayUid, 'id' => $card['id']]);
}
$payload = collabEncryptPayload(collabBuildCardPayload($card));
if ($payload === null) continue; // no relay_key or openssl failure
$res = collabRelayCall('/api/collab-cards', 'POST', [
'bucket_uid' => $card['bucket_uid'],
'card_uid' => $relayUid,
'payload' => $payload,
]);
if (!($res['ok'] ?? false)) continue; // retry next poll
$stateUpsert->execute([
'cid' => $card['id'],
'cid2' => $card['id'],
'ru' => $relayUid,
'bu' => $card['bucket_uid'],
'push' => $res['updated_at'] ?? $card['updated_at'],
]);
$stats['pushed']++;
}
// ── 3. Pull deltas ─────────────────────────────────────
$since = $db->query("SELECT value FROM settings WHERE key = 'collab_cards_last_remote_at'")->fetchColumn();
if (!$since) $since = '';
$qs = $since !== '' ? ('?since=' . urlencode($since)) : '';
$res = collabRelayCall('/api/collab-cards' . $qs);
if (!($res['ok'] ?? false)) return $stats;
$incoming = $res['cards'] ?? [];
$maxTs = $res['max_updated_at'] ?? $since;
if (!$incoming) {
if ($maxTs !== $since) collabSaveSetting('collab_cards_last_remote_at', $maxTs);
return $stats;
}
// The delete trigger fires unconditionally on collab-card deletes,
// including the ones the apply phase itself causes. To avoid looping
// the delete back to the relay we snapshot the highest tombstone id
// before apply and purge everything > that marker afterwards.
$tombstoneMark = (int)$db->query('SELECT COALESCE(MAX(id), 0) FROM collab_tombstones')->fetchColumn();
try {
foreach ($incoming as $c) {
// Resolve local bucket id
$bid = $db->prepare('SELECT id FROM buckets WHERE collab_uid = :u');
$bid->execute(['u' => $c['bucket_uid']]);
$localBucketId = $bid->fetchColumn();
if (!$localBucketId) continue; // bucket mirror missing — shouldn't happen
// Find local card by relay_uid — grab all fields so we can
// stash a conflict copy if needed before overwriting
$find = $db->prepare('SELECT id, title, body, color, tags, position, due_date, updated_at FROM cards WHERE relay_uid = :u');
$find->execute(['u' => $c['card_uid']]);
$local = $find->fetch();
if (!empty($c['deleted'])) {
if ($local) {
$db->prepare('DELETE FROM cards WHERE id = :id')->execute(['id' => $local['id']]);
$db->prepare('DELETE FROM collab_card_state WHERE card_id = :id')->execute(['id' => $local['id']]);
$stats['deleted_local']++;
}
continue;
}
// Decrypt payload
$fields = collabDecryptPayload($c['payload'] ?? '');
if ($fields === null) continue;
if ($local) {
// LWW: only accept remote if it's strictly newer than local
if (($local['updated_at'] ?? '') >= ($c['updated_at'] ?? '')) continue;
// Conflict detection: if the local card has local edits that
// were never pushed (local.updated_at > last_push_at), we're
// about to clobber them. Stash a copy into the local-only
// Conflicts bucket before overwriting.
$cs = $db->prepare('SELECT last_push_at FROM collab_card_state WHERE card_id = :id');
$cs->execute(['id' => $local['id']]);
$lastPushAt = $cs->fetchColumn();
if ($lastPushAt === false) $lastPushAt = null;
$hasUnpushed = $lastPushAt === null
? false // first apply of a remote card — no "local edits" yet
: ($local['updated_at'] ?? '') > $lastPushAt;
if ($hasUnpushed) {
collabStashConflict($db, $local, (string)($c['author_username'] ?? ''));
$stats['conflicts']++;
}
$db->prepare("
UPDATE cards SET
title = :t, body = :b, color = :c, tags = :tg,
position = :p, due_date = :d,
bucket_id = :bid,
updated_at = :ua
WHERE id = :id
")->execute([
't' => $fields['title'] ?? '',
'b' => $fields['body'] ?? '',
'c' => $fields['color'] ?? '#38bdf8',
'tg' => $fields['tags'] ?? '',
'p' => (int)($fields['position'] ?? 0),
'd' => $fields['due_date'] ?? null,
'bid' => $localBucketId,
'ua' => $c['updated_at'],
'id' => $local['id'],
]);
$localId = (int)$local['id'];
} else {
// New card
$db->prepare("
INSERT INTO cards (title, body, color, tags, position, due_date, bucket_id, relay_uid, created_at, updated_at)
VALUES (:t, :b, :c, :tg, :p, :d, :bid, :u, :ca, :ua)
")->execute([
't' => $fields['title'] ?? '',
'b' => $fields['body'] ?? '',
'c' => $fields['color'] ?? '#38bdf8',
'tg' => $fields['tags'] ?? '',
'p' => (int)($fields['position'] ?? 0),
'd' => $fields['due_date'] ?? null,
'bid' => $localBucketId,
'u' => $c['card_uid'],
'ca' => $c['updated_at'],
'ua' => $c['updated_at'],
]);
$localId = (int)$db->lastInsertId();
}
// Record the remote state so we don't push it back on the next poll
$db->prepare("
INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at)
VALUES (:cid, :ru, :bu, :p, :r)
ON CONFLICT(card_id) DO UPDATE SET
relay_uid = excluded.relay_uid,
bucket_uid = excluded.bucket_uid,
last_push_at = excluded.last_push_at,
last_remote_at = excluded.last_remote_at
")->execute([
'cid' => $localId,
'ru' => $c['card_uid'],
'bu' => $c['bucket_uid'],
'p' => $c['updated_at'],
'r' => $c['updated_at'],
]);
$stats['pulled']++;
}
} finally {
// Purge any tombstones the apply phase itself created. Tombstones
// queued before apply (by actual user deletes) survive and get
// drained on the next poll iteration.
$db->prepare('DELETE FROM collab_tombstones WHERE id > :m')->execute(['m' => $tombstoneMark]);
}
if ($maxTs !== $since) collabSaveSetting('collab_cards_last_remote_at', $maxTs);
return $stats;
}
/** Tiny key/value on the core `settings` table. */
function collabSaveSetting(string $key, string $value): void {
Database::connect()
->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (:k, :v)")
->execute(['k' => $key, 'v' => $value]);
}
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
// ── List local mirror ──────────────────────────────────────
if ($route === '/api/collab-buckets' && $method === 'GET') {
$db = Database::connect();
$rows = $db->query('
SELECT uid, name, color, owner_username, members_json, created_at, updated_at, last_seen_at
FROM collab_buckets
ORDER BY updated_at DESC
')->fetchAll();
$out = array_map('collabShapeLocalBucket', $rows);
collabJson(['ok' => true, 'buckets' => $out]);
}
// ── Create on relay, mirror local ──────────────────────────
// Resilient path: always create the local bucket first so the user
// can keep working even when the relay is down. Then attempt to
// upgrade it via the shared helper. On relay failure we queue the
// upgrade in collab_bucket_outbox and the next poll retries.
if ($route === '/api/collab-buckets' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$name = trim((string)($input['name'] ?? ''));
$color = (string)($input['color'] ?? '#38bdf8');
$members = is_array($input['members'] ?? null) ? $input['members'] : [];
if ($name === '') collabJson(['ok' => false, 'error' => 'name required'], 400);
$db = Database::connect();
$maxPos = (int)$db->query('SELECT COALESCE(MAX(position), 0) FROM buckets')->fetchColumn();
$db->prepare('INSERT INTO buckets (name, color, position) VALUES (:n, :c, :p)')
->execute(['n' => $name, 'c' => $color, 'p' => $maxPos + 1]);
$bucketId = (int)$db->lastInsertId();
$up = collabAttemptUpgrade($bucketId, $members);
if ($up['ok'] ?? false) {
collabJson(['ok' => true, 'uid' => $up['uid'], 'bucket_id' => $bucketId]);
}
// Relay unreachable: queue the upgrade for retry. The bucket
// already exists locally so the user sees it immediately.
$db->prepare('INSERT INTO collab_bucket_outbox (bucket_id, members_json) VALUES (:b, :m)')
->execute(['b' => $bucketId, 'm' => json_encode($members)]);
collabJson([
'ok' => true,
'queued' => true,
'bucket_id' => $bucketId,
'error' => $up['error'] ?? 'relay unreachable — will retry on next poll',
]);
}
// ── Update on relay (owner-only) ───────────────────────────
if (preg_match('#^/api/collab-buckets/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'PATCH') {
$uid = $m[1];
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$res = collabRelayCall('/api/collab-buckets/' . urlencode($uid), 'PATCH', $input);
if (!($res['ok'] ?? false)) {
collabJson(['ok' => false, 'error' => $res['error'] ?? 'patch failed'], 500);
}
// Don't mirror the patch locally — the next poll will pick up the new
// state from the relay (keeps the relay as source of truth).
collabJson(['ok' => true]);
}
// ── Delete / leave ─────────────────────────────────────────
if (preg_match('#^/api/collab-buckets/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') {
$uid = $m[1];
$res = collabRelayCall('/api/collab-buckets/' . urlencode($uid), 'DELETE');
if (!($res['ok'] ?? false)) {
collabJson(['ok' => false, 'error' => $res['error'] ?? 'delete failed'], 500);
}
Database::connect()->prepare('DELETE FROM collab_buckets WHERE uid = :u')->execute(['u' => $uid]);
collabDeleteMainBucket($uid);
collabJson(['ok' => true]);
}
// ── Poll: full resync ──────────────────────────────────────
if ($route === '/api/collab-buckets/poll' && $method === 'POST') {
$db = Database::connect();
// Phase 5: drain the create/upgrade outbox first. Each queued row
// is a "tried to make this local bucket collab but the relay was
// down" intent. Retry the upgrade helper; on success drop the
// outbox row; on continued failure leave it queued for next poll.
$outbox = $db->query('SELECT id, bucket_id, members_json FROM collab_bucket_outbox ORDER BY id ASC')->fetchAll();
$outboxProcessed = 0;
foreach ($outbox as $o) {
$members = json_decode($o['members_json'] ?? '[]', true) ?: [];
$up = collabAttemptUpgrade((int)$o['bucket_id'], $members);
if ($up['ok'] ?? false) {
$db->prepare('DELETE FROM collab_bucket_outbox WHERE id = :id')->execute(['id' => $o['id']]);
$outboxProcessed++;
} elseif (strpos((string)($up['error'] ?? ''), 'bucket not found') !== false) {
// Bucket was deleted locally before the upgrade could fly;
// drop the stale outbox entry instead of retrying forever.
$db->prepare('DELETE FROM collab_bucket_outbox WHERE id = :id')->execute(['id' => $o['id']]);
}
// All other failures: leave queued, retry next poll.
}
$res = collabRelayCall('/api/collab-buckets');
if (!($res['ok'] ?? false)) {
collabJson(['ok' => false, 'error' => $res['error'] ?? 'poll failed']);
}
$remote = $res['buckets'] ?? [];
$remoteUids = array_column($remote, 'uid');
// Any local bucket that is no longer on the relay for us = we were
// removed (or the bucket was deleted). Drop both the plugin mirror
// and the corresponding row in the main buckets table.
$staleUids = [];
if ($remoteUids) {
$ph = implode(',', array_fill(0, count($remoteUids), '?'));
$sel = $db->prepare("SELECT uid FROM collab_buckets WHERE uid NOT IN ($ph)");
$sel->execute($remoteUids);
$staleUids = array_column($sel->fetchAll(), 'uid');
if ($staleUids) {
$stmt = $db->prepare("DELETE FROM collab_buckets WHERE uid NOT IN ($ph)");
$stmt->execute($remoteUids);
}
} else {
$staleUids = array_column($db->query('SELECT uid FROM collab_buckets')->fetchAll(), 'uid');
$db->exec('DELETE FROM collab_buckets');
}
foreach ($staleUids as $uid) {
collabDeleteMainBucket($uid);
}
// Upsert each remote bucket. Preserve local last_seen_at so the dot
// state survives across polls.
$changed = 0;
$upsert = $db->prepare("
INSERT INTO collab_buckets
(uid, name, color, owner_username, members_json, created_at, updated_at, last_seen_at)
VALUES
(:uid, :n, :c, :ou, :m, :ca, :ua,
COALESCE((SELECT last_seen_at FROM collab_buckets WHERE uid = :uid2), NULL))
ON CONFLICT(uid) DO UPDATE SET
name = excluded.name,
color = excluded.color,
owner_username = excluded.owner_username,
members_json = excluded.members_json,
created_at = excluded.created_at,
updated_at = excluded.updated_at
");
$check = $db->prepare('SELECT updated_at FROM collab_buckets WHERE uid = :u');
foreach ($remote as $b) {
// Always mirror into main buckets table (idempotent upsert)
collabUpsertMainBucket($b['uid'], $b['name'], $b['color']);
$check->execute(['u' => $b['uid']]);
$existing = $check->fetchColumn();
if ($existing === ($b['updated_at'] ?? '')) continue; // unchanged, skip plugin-table write
$upsert->execute([
'uid' => $b['uid'],
'n' => $b['name'],
'c' => $b['color'],
'ou' => $b['owner_username'],
'm' => json_encode($b['members'] ?? []),
'ca' => $b['created_at'],
'ua' => $b['updated_at'],
'uid2' => $b['uid'],
]);
$changed++;
}
// ── Phase 3: card sync ─────────────────────────────────
$cardStats = collabSyncCards($db);
// If anything changed locally we hint to the frontend to reload
$cardsChanged = (
$cardStats['pushed']
+ $cardStats['pulled']
+ $cardStats['deleted_local']
+ $cardStats['tombstones_sent']
+ $cardStats['conflicts']
+ $cardStats['moved']
) > 0;
// ── Phase 4: fold the current lock list into the poll response
// so the frontend can decorate cards without a second round-trip.
$locks = [];
$lockRes = collabRelayCall('/api/collab-locks');
if (($lockRes['ok'] ?? false) && is_array($lockRes['locks'] ?? null)) {
$locks = $lockRes['locks'];
}
// Any pending outbox entries still waiting for a reachable relay
$outboxPending = (int)$db->query('SELECT COUNT(*) FROM collab_bucket_outbox')->fetchColumn();
collabJson([
'ok' => true,
'changed' => $changed,
'total' => count($remote),
'cards_changed' => $cardsChanged,
'card_stats' => $cardStats,
'locks' => $locks,
'outbox_processed' => $outboxProcessed,
'outbox_pending' => $outboxPending,
]);
}
// ── Mark a bucket as seen (clears dot + stops pulse next render) ──
if ($route === '/api/collab-buckets/mark-seen' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$uid = trim((string)($input['uid'] ?? ''));
if ($uid === '') collabJson(['ok' => false, 'error' => 'uid required'], 400);
Database::connect()
->prepare("UPDATE collab_buckets SET last_seen_at = datetime('now') WHERE uid = :u")
->execute(['u' => $uid]);
collabJson(['ok' => true]);
}
// ── Proxy: relay tenants (for the create-bucket member picker) ──
// The relay's /api/tenants returns {tenants, self} without an `ok` field,
// so we wrap it here to match the shape the frontend expects.
if ($route === '/api/collab-buckets/relay-tenants' && $method === 'GET') {
$res = collabRelayCall('/api/tenants');
if (isset($res['ok']) && $res['ok'] === false) {
collabJson($res);
}
collabJson([
'ok' => true,
'tenants' => $res['tenants'] ?? [],
'self' => $res['self'] ?? collabRelaySettings()['user'],
]);
}
// ── Phase 5: upgrade an existing local bucket to collab ───
// Keeps the same local buckets.id (so cards stay attached), writes a
// new collab_uid onto the row, and creates the mirror on the relay.
// On the very next poll the push phase sees every card in that bucket
// as dirty (no collab_card_state rows) and uploads them. If the relay
// is unreachable, the upgrade is queued in the outbox and retried on
// the next poll.
if ($route === '/api/collab-buckets/upgrade' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$bucketId = (int)($input['bucket_id'] ?? 0);
$members = is_array($input['members'] ?? null) ? $input['members'] : [];
if ($bucketId <= 0) collabJson(['ok' => false, 'error' => 'bucket_id required'], 400);
$db = Database::connect();
$up = collabAttemptUpgrade($bucketId, $members);
if ($up['ok'] ?? false) {
if (!empty($up['already'])) collabJson(['ok' => false, 'error' => 'bucket is already collaborative'], 400);
collabJson(['ok' => true, 'uid' => $up['uid']]);
}
// Relay unreachable — queue retry
$db->prepare('INSERT INTO collab_bucket_outbox (bucket_id, members_json) VALUES (:b, :m)')
->execute(['b' => $bucketId, 'm' => json_encode($members)]);
collabJson([
'ok' => true,
'queued' => true,
'error' => $up['error'] ?? 'relay unreachable — will retry on next poll',
]);
}
// ── Phase 4: edit-lock passthrough ─────────────────────────
if ($route === '/api/collab-locks' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$res = collabRelayCall('/api/collab-locks', 'POST', $input);
collabJson($res);
}
if (preg_match('#^/api/collab-locks/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') {
$res = collabRelayCall('/api/collab-locks/' . urlencode($m[1]) . '/' . urlencode($m[2]), 'DELETE');
collabJson($res);
}
});
@@ -0,0 +1,304 @@
/* ── Collab buckets plugin ── */
/* Decoration for bucket-bar tabs that represent a collab bucket. The core
still renders the tab via its normal `.bucket-tab` rule; the plugin only
layers a few extras on top: a small 👥 icon swap (done in JS), an unread
dot pinned top-right, and a 3-cycle pulse animation when a fresh update
arrives. */
.bucket-tab.bucket-tab-collab {
position: relative;
/* Subtle left emphasis so collab tabs read differently at a glance */
box-shadow: inset 2px 0 0 0 color-mix(in srgb, var(--t-accent2) 70%, transparent);
}
.bucket-tab.bucket-tab-collab .collab-tab-dot {
position: absolute;
top: -3px;
right: -3px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--t-danger);
border: 1px solid var(--t-bg);
box-shadow: 0 0 6px color-mix(in srgb, var(--t-danger) 60%, transparent);
pointer-events: none;
}
.bucket-tab.bucket-tab-collab.pulsing {
animation: collab-bucket-pulse 1.2s ease-in-out 3;
}
/* Keep the 3-cycle cadence regardless of global animation-speed override */
body.anim-fast .bucket-tab.bucket-tab-collab.pulsing,
body.anim-slow .bucket-tab.bucket-tab-collab.pulsing {
animation: collab-bucket-pulse 1.2s ease-in-out 3 !important;
animation-duration: 1.2s !important;
}
@keyframes collab-bucket-pulse {
0%, 100% {
box-shadow: inset 2px 0 0 0 color-mix(in srgb, var(--t-accent2) 70%, transparent),
0 0 0 0 color-mix(in srgb, var(--t-danger) 60%, transparent);
}
50% {
box-shadow: inset 2px 0 0 0 color-mix(in srgb, var(--t-accent2) 70%, transparent),
0 0 0 4px color-mix(in srgb, var(--t-danger) 0%, transparent);
}
}
/* ── Create / edit modal (used by the + Bucket button when the plugin
is loaded) ── */
.collab-modal-overlay {
position: fixed;
inset: 0;
z-index: 240;
background: var(--t-overlay, rgba(0,0,0,0.6));
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
backdrop-filter: blur(var(--ui-blur-overlay, 0px));
-webkit-backdrop-filter: blur(var(--ui-blur-overlay, 0px));
}
.collab-modal {
width: 100%;
max-width: 480px;
max-height: 85vh;
background: var(--t-surface);
border: 1px solid var(--t-border);
border-radius: var(--ui-radius-modal);
box-shadow: var(--ui-shadow-modal);
display: flex;
flex-direction: column;
overflow: hidden;
}
.collab-modal-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid var(--t-border);
flex-shrink: 0;
}
.collab-modal-close {
background: transparent;
border: 1px solid var(--t-border);
border-radius: var(--ui-radius-button);
color: var(--t-muted);
cursor: pointer;
padding: 4px 8px;
display: flex;
align-items: center;
transition: color 0.12s, border-color 0.12s;
}
.collab-modal-close:hover { color: var(--t-danger); border-color: var(--t-danger); }
.collab-modal-body {
padding: 16px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.collab-field { display: block; margin-bottom: 14px; }
.collab-field:last-child { margin-bottom: 0; }
.collab-field.hidden { display: none; }
.collab-label {
display: block;
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
font-size: 9px;
color: var(--t-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 5px;
}
.collab-input {
width: 100%;
background: var(--t-bg);
border: 1px solid var(--t-border);
border-radius: var(--ui-radius-input);
color: var(--t-bright);
padding: 7px 10px;
font-size: 12px;
font-family: var(--t-font-content, 'JetBrains Mono', monospace);
outline: none;
transition: border-color 0.12s;
}
.collab-input:focus { border-color: var(--t-accent); }
.collab-color {
width: 52px;
height: 32px;
border: 1px solid var(--t-border);
border-radius: var(--ui-radius-input);
background: transparent;
cursor: pointer;
padding: 2px;
}
.collab-toggle-row {
display: flex !important;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: var(--t-bg);
border: 1px solid var(--t-border);
border-radius: var(--ui-radius-input);
cursor: pointer;
font-size: 12px;
color: var(--t-text);
user-select: none;
}
.collab-toggle-row input { accent-color: var(--t-accent); cursor: pointer; }
.collab-toggle-row:hover { border-color: var(--t-accent); color: var(--t-bright); }
.collab-members-wrap { margin-top: 10px; }
.collab-members {
border: 1px solid var(--t-border);
border-radius: var(--ui-radius-input);
background: var(--t-bg);
max-height: 200px;
overflow-y: auto;
padding: 4px;
}
.collab-members-empty {
font-size: 11px;
color: var(--t-dim);
font-style: italic;
padding: 8px 10px;
}
.collab-member-row {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
cursor: pointer;
font-size: 12px;
color: var(--t-text);
border-radius: 2px;
user-select: none;
}
.collab-member-row:hover {
background: var(--t-raised);
color: var(--t-bright);
}
.collab-member-row input { cursor: pointer; accent-color: var(--t-accent); }
.collab-modal-footer {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--t-border);
flex-shrink: 0;
}
.collab-modal-status {
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
font-size: 9px;
color: var(--t-dim);
min-height: 14px;
}
.collab-btn-cancel,
.collab-btn-submit {
padding: 6px 14px;
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
font-size: 10px;
background: transparent;
cursor: pointer;
border: 1px solid var(--t-border);
border-radius: var(--ui-radius-button);
color: var(--t-muted);
transition: color 0.12s, border-color 0.12s, background 0.12s;
}
.collab-btn-cancel:hover {
color: var(--t-danger);
border-color: var(--t-danger);
}
.collab-btn-submit {
color: var(--t-accent);
border-color: var(--t-accent);
}
.collab-btn-submit:hover {
background: color-mix(in srgb, var(--t-accent) 12%, transparent);
}
.collab-readonly {
display: block;
padding: 7px 10px;
background: var(--t-bg);
border: 1px solid var(--t-border);
border-radius: var(--ui-radius-input);
color: var(--t-text);
font-family: var(--t-font-content, 'JetBrains Mono', monospace);
font-size: 12px;
}
.collab-hint {
margin-top: 4px;
font-size: 10px;
color: var(--t-dim);
font-style: italic;
}
/* ── Phase 4: edit-lock indicator on board cards ── */
.board-card.collab-locked {
box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--t-accent2) 55%, transparent);
}
.collab-lock-badge {
position: absolute;
top: 6px;
right: 6px;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
color: var(--t-accent2);
background: color-mix(in srgb, var(--t-surface) 92%, transparent);
border: 1px solid color-mix(in srgb, var(--t-accent2) 60%, var(--t-border));
border-radius: 50%;
z-index: 6;
pointer-events: none;
font-size: 11px;
}
/* ── Conflict toast (LWW losers stashed into the Conflicts bucket) ── */
.collab-conflict-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 220;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px 10px 12px;
max-width: 480px;
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
font-size: 10px;
color: var(--t-bright);
background: var(--t-surface);
border: 1px solid var(--t-danger);
border-radius: var(--ui-radius-modal);
box-shadow: var(--ui-shadow-modal);
transition: opacity 0.4s;
}
.collab-conflict-toast i.ph-warning { color: var(--t-danger); flex-shrink: 0; }
.collab-conflict-toast b { color: var(--t-danger); font-weight: 700; }
.collab-conflict-toast.fade-out { opacity: 0; }
/* Queued variant reuses the layout but switches the danger accents for
a neutral info color — used for "relay was down, bucket is queued" and
"queued bucket finally synced" messages. */
.collab-conflict-toast.collab-queued-toast {
border-color: var(--t-accent2);
}
.collab-conflict-toast.collab-queued-toast i.ph-cloud-arrow-up {
color: var(--t-accent2);
flex-shrink: 0;
}
.collab-conflict-close {
background: transparent;
border: none;
color: var(--t-dim);
cursor: pointer;
padding: 2px 4px;
margin-left: 6px;
display: flex;
align-items: center;
}
.collab-conflict-close:hover { color: var(--t-danger); }
@@ -0,0 +1,719 @@
// ── Collab Buckets Plugin Frontend ──
//
// Phase 1+2 shape (post-refactor): collab buckets are first-class bucket
// rows in the standard bucket bar. The plugin just owns the sync + the
// visual unread-state decoration:
//
// - Polls /api/collab-buckets/poll every POLL_INTERVAL_MS. The backend
// mirrors each remote bucket into the main `buckets` table so the core
// bucket bar renders it automatically.
// - Exposes `window._collabBucketsCreateDialog` — an async function that
// the core + Bucket button calls so we can pop a richer "new bucket"
// modal with an optional "collaborative" checkbox + member picker.
// - Watches #bucket-bar via MutationObserver. After every render, finds
// tabs whose data-bucket id corresponds to a collab bucket and attaches
// a pulse animation + red unread dot if the bucket has unread state.
// - Delegated click handler on #bucket-bar calls mark-seen for collab tabs.
//
// The plugin never touches the header anymore. No more standalone tiles.
const hyprcrd = window.hyprcrd;
if (!hyprcrd) throw new Error('[collab-buckets] window.hyprcrd missing');
const $ = (id) => document.getElementById(id);
// uid -> collab bucket meta (from /api/collab-buckets)
let collabByUid = new Map();
let pollHandle = null;
const POLL_INTERVAL_MS = 30000;
// Phase 4: edit locks
// card_uid -> { bucket_uid, holder_username, expires_at }
let collabLocks = new Map();
// Active heartbeat for the currently open editor (if editing a collab card)
let activeLock = null; // { bucket_uid, card_uid, timer }
const LOCK_HEARTBEAT_MS = 30000;
// ── Helpers ───────────────────────────────────────────────────────
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// ── Loading / polling ─────────────────────────────────────────────
async function loadCollabMeta() {
try {
const res = await fetch('api/collab-buckets').then(r => r.json());
if (!res?.ok) return;
const next = new Map();
for (const b of res.buckets || []) next.set(b.uid, b);
collabByUid = next;
// Expose for other plugin code + for debugging
window._collabBucketsByUid = collabByUid;
decorateBucketBar();
} catch (err) { /* silent; relay may be offline */ }
}
async function pollOnce() {
try {
const res = await hyprcrd.api('api/collab-buckets/poll', { method: 'POST' });
if (!res?.ok) return;
// Reload if:
// * a bucket changed (metadata / members)
// * the bucket set shrunk or grew
// * card sync pushed / pulled / deleted anything (Phase 3)
const bucketChanged = res.changed > 0 || res.total !== collabByUid.size;
const cardsChanged = !!res.cards_changed;
if (bucketChanged || cardsChanged) {
await loadCollabMeta();
if (typeof window._reloadAll === 'function') await window._reloadAll();
}
// If LWW clobbered local edits, the backend stashed them in a local
// "Conflicts" bucket — surface that visibly so the user knows.
const conflicts = res.card_stats?.conflicts || 0;
if (conflicts > 0) {
showConflictToast(conflicts);
}
// Phase 5: if a queued bucket create finally pushed through, tell
// the user. Retry failures stay silent.
if ((res.outbox_processed || 0) > 0) {
const n = res.outbox_processed;
showQueuedToast(`${n} queued bucket${n === 1 ? '' : 's'} synced to the relay.`);
}
// Phase 4: edit-lock list comes back with the poll
if (Array.isArray(res.locks)) {
const next = new Map();
for (const l of res.locks) next.set(l.card_uid, l);
collabLocks = next;
window._collabLocks = collabLocks;
decorateBoardLocks();
}
} catch { /* silent */ }
}
function showConflictToast(n) {
const existing = document.getElementById('collab-conflict-toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.id = 'collab-conflict-toast';
toast.className = 'collab-conflict-toast';
toast.innerHTML = `
<i class="ph ph-warning text-sm"></i>
<span>${n} local change${n === 1 ? '' : 's'} overwritten by a collaborator — backup in the <b>Conflicts</b> bucket.</span>
<button type="button" class="collab-conflict-close" aria-label="dismiss"><i class="ph ph-x text-sm"></i></button>
`;
document.body.appendChild(toast);
toast.querySelector('.collab-conflict-close').addEventListener('click', () => toast.remove());
setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 10000);
}
/** Informational toast — used when the relay is unreachable and a bucket
* create was queued, or when the outbox retry succeeds later. */
function showQueuedToast(message) {
const existing = document.getElementById('collab-queued-toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.id = 'collab-queued-toast';
toast.className = 'collab-conflict-toast collab-queued-toast';
toast.innerHTML = `
<i class="ph ph-cloud-arrow-up text-sm"></i>
<span>${escapeHtml(message)}</span>
<button type="button" class="collab-conflict-close" aria-label="dismiss"><i class="ph ph-x text-sm"></i></button>
`;
document.body.appendChild(toast);
toast.querySelector('.collab-conflict-close').addEventListener('click', () => toast.remove());
setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 6000);
}
function startPolling() {
stopPolling();
pollHandle = setInterval(pollOnce, POLL_INTERVAL_MS);
}
function stopPolling() {
if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
}
// ── Bucket bar decoration ─────────────────────────────────────────
/**
* For every tab in #bucket-bar whose bucket has a collab_uid, apply
* collab-specific classes (.bucket-tab-collab, .has-unread, .pulsing)
* and inject an unread dot. Runs after every core re-render via the
* MutationObserver below.
*/
function decorateBucketBar() {
const bar = document.getElementById('bucket-bar');
if (!bar || !window.state?.buckets) return;
// Build bucket-id -> row map so we can look up collab_uid
const byId = new Map();
for (const b of window.state.buckets) byId.set(String(b.id), b);
for (const tab of bar.querySelectorAll('.bucket-tab[data-bucket]')) {
const bid = tab.dataset.bucket;
const row = byId.get(bid);
if (!row || !row.collab_uid) continue;
tab.classList.add('bucket-tab-collab');
tab.dataset.collabUid = row.collab_uid;
// Replace the default folder icon with the "users" icon so the tab
// reads as collaborative at a glance. We query by class and swap
// the phosphor icon class set. Safe to run on every render — the
// "icon swap" is idempotent.
const icon = tab.querySelector('i.ph');
if (icon && !icon.classList.contains('ph-users-three')) {
icon.classList.remove('ph-folder');
icon.classList.add('ph-users-three');
}
// Unread state
const meta = collabByUid.get(row.collab_uid);
const wasUnread = tab.classList.contains('has-unread');
const isUnread = !!meta?.has_unread;
tab.classList.toggle('has-unread', isUnread);
// Inject / remove dot element
let dot = tab.querySelector('.collab-tab-dot');
if (isUnread && !dot) {
dot = document.createElement('span');
dot.className = 'collab-tab-dot';
tab.appendChild(dot);
} else if (!isUnread && dot) {
dot.remove();
}
// Fresh unread -> pulse 3x. JS strips the class after 3.7s so
// subsequent decorate-calls don't retrigger mid-animation.
if (isUnread && !wasUnread) {
tab.classList.add('pulsing');
setTimeout(() => tab.classList.remove('pulsing'), 3700);
}
}
}
// ── Mark-seen on click (delegated, fires after core's click handler) ──
function setupClickInterceptor() {
const bar = document.getElementById('bucket-bar');
if (!bar) return;
bar.addEventListener('click', async (e) => {
const tab = e.target.closest('.bucket-tab[data-bucket]');
if (!tab) return;
const uid = tab.dataset.collabUid;
if (!uid) return;
// Clear unread locally immediately for snappy feedback
const meta = collabByUid.get(uid);
if (meta) meta.has_unread = false;
tab.classList.remove('has-unread', 'pulsing');
tab.querySelector('.collab-tab-dot')?.remove();
// Persist to the plugin backend
hyprcrd.api('api/collab-buckets/mark-seen', { method: 'POST', body: { uid } }).catch(() => {});
});
}
// ── MutationObserver: redecorate on every bar re-render ───────────
function setupBarObserver() {
const bar = document.getElementById('bucket-bar');
if (!bar) return;
const obs = new MutationObserver(() => decorateBucketBar());
obs.observe(bar, { childList: true, subtree: true });
}
// ── Create dialog (hooked by core's + Bucket button) ──────────────
async function fetchRelayTenants() {
try {
const res = await hyprcrd.api('api/collab-buckets/relay-tenants');
return res?.ok ? res : null;
} catch { return null; }
}
/**
* Shown when the user clicks the core "+ Bucket" button. Returns true when
* a bucket was created (so core can reload) and false / null when canceled.
*/
async function createBucketDialog() {
// Lazy-load the relay tenants so a local-only bucket doesn't pay for
// the round trip when the user just wants a simple bucket.
const relayP = fetchRelayTenants();
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'collab-modal-overlay';
overlay.innerHTML = `
<div class="collab-modal">
<div class="collab-modal-header">
<i class="ph ph-folder-plus text-t-accent"></i>
<span class="font-pixel text-xs text-t-accent">new bucket</span>
<div style="flex:1"></div>
<button class="collab-modal-close" type="button" aria-label="close"><i class="ph ph-x text-sm"></i></button>
</div>
<div class="collab-modal-body">
<label class="collab-field">
<span class="collab-label">Name</span>
<input type="text" class="collab-input" id="__cb_name" placeholder="bucket name...">
</label>
<label class="collab-field">
<span class="collab-label">Color</span>
<input type="color" class="collab-color" id="__cb_color" value="#64748b">
</label>
<label class="collab-field collab-toggle-row">
<input type="checkbox" id="__cb_collab">
<span>Collaborative — share with relay users</span>
</label>
<div class="collab-field collab-members-wrap hidden" id="__cb_members_wrap">
<span class="collab-label">Members</span>
<div class="collab-members" id="__cb_members_list">
<div class="collab-members-empty">loading…</div>
</div>
</div>
</div>
<div class="collab-modal-footer">
<div class="collab-modal-status" id="__cb_status"></div>
<div style="flex:1"></div>
<button class="collab-btn-cancel" type="button">Cancel</button>
<button class="collab-btn-submit" type="button">Create</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const done = (created) => { overlay.remove(); resolve(created); };
overlay.querySelector('.collab-modal-close').addEventListener('click', () => done(false));
overlay.querySelector('.collab-btn-cancel').addEventListener('click', () => done(false));
overlay.addEventListener('click', (e) => { if (e.target === overlay) done(false); });
document.addEventListener('keydown', function esc(e) {
if (e.key === 'Escape') { done(false); document.removeEventListener('keydown', esc); }
});
const collabEl = overlay.querySelector('#__cb_collab');
const membersWrap = overlay.querySelector('#__cb_members_wrap');
const membersList = overlay.querySelector('#__cb_members_list');
const status = overlay.querySelector('#__cb_status');
let peers = [];
let tenantsLoaded = false;
collabEl.addEventListener('change', async () => {
if (!collabEl.checked) {
membersWrap.classList.add('hidden');
return;
}
membersWrap.classList.remove('hidden');
if (tenantsLoaded) return;
const relay = await relayP;
tenantsLoaded = true;
if (!relay) {
membersList.innerHTML = '<div class="collab-members-empty">could not reach the relay — uncheck collaborative to create a local bucket</div>';
return;
}
const self = relay.self || '';
peers = (relay.tenants || []).filter(t => (t.username || t) !== self);
if (!peers.length) {
membersList.innerHTML = '<div class="collab-members-empty">no other relay users</div>';
return;
}
membersList.innerHTML = peers.map(t => {
const u = typeof t === 'string' ? t : (t.username || '');
return `<label class="collab-member-row"><input type="checkbox" value="${escapeHtml(u)}"><span>${escapeHtml(u)}</span></label>`;
}).join('');
});
overlay.querySelector('.collab-btn-submit').addEventListener('click', async () => {
const name = overlay.querySelector('#__cb_name').value.trim();
const color = overlay.querySelector('#__cb_color').value;
const isCollab = collabEl.checked;
if (!name) { status.textContent = 'name required'; status.style.color = 'var(--t-danger)'; return; }
status.textContent = 'creating…';
status.style.color = 'var(--t-dim)';
try {
if (isCollab) {
const members = [...overlay.querySelectorAll('#__cb_members_list input:checked')].map(c => c.value);
const res = await hyprcrd.api('api/collab-buckets', { method: 'POST', body: { name, color, members } });
if (!res?.ok) { status.textContent = res?.error || 'failed'; status.style.color = 'var(--t-danger)'; return; }
// Relay was unreachable; the bucket exists locally and the
// upgrade is queued. Show a toast so the user knows.
if (res.queued) {
showQueuedToast('Bucket created locally — will sync to the relay on the next successful poll.');
}
} else {
// Regular bucket: POST /api/buckets (core route), then PATCH color.
const created = await hyprcrd.api('api/buckets', { method: 'POST', body: { name } });
if (created?.id && color) {
await hyprcrd.api(`api/buckets/${created.id}`, { method: 'PATCH', body: { color } });
}
}
await loadCollabMeta();
done(true);
} catch (err) {
status.textContent = String(err?.message || err);
status.style.color = 'var(--t-danger)';
}
});
setTimeout(() => overlay.querySelector('#__cb_name')?.focus(), 50);
});
}
// Expose for core's + Bucket handler
window._collabBucketsCreateDialog = createBucketDialog;
/**
* Edit dialog for an existing collab bucket. Shown by the core bucket
* context menu's "Members & owner…" item. The current owner sees a
* member picker + an owner dropdown; other members see everything as
* read-only labels since only the owner can push updates. Returns true
* when the PATCH succeeded so core can reload.
*/
async function editBucketDialog(collabUid) {
const bucket = collabByUid.get(collabUid);
if (!bucket) return false;
const relay = await fetchRelayTenants();
if (!relay) {
await hyprcrd.dialog.show({
title: 'collab buckets',
message: 'Could not reach the relay.',
okLabel: 'Ok', cancelLabel: '',
});
return false;
}
const self = relay.self || '';
const allTenants = (relay.tenants || []).map(t => typeof t === 'string' ? t : (t.username || ''));
const peers = allTenants.filter(u => u && u !== self);
const isOwner = bucket.owner_username === self;
const currentMembers = new Set((bucket.members || []).filter(u => u && u !== self));
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'collab-modal-overlay';
overlay.innerHTML = `
<div class="collab-modal">
<div class="collab-modal-header">
<i class="ph ph-users-three text-t-accent"></i>
<span class="font-pixel text-xs text-t-accent">${escapeHtml(bucket.name)} — members</span>
<div style="flex:1"></div>
<button class="collab-modal-close" type="button" aria-label="close"><i class="ph ph-x text-sm"></i></button>
</div>
<div class="collab-modal-body">
<div class="collab-field">
<span class="collab-label">Owner</span>
${isOwner
? `<select class="collab-input" id="__cb_owner">
<option value="${escapeHtml(self)}">${escapeHtml(self)} (you)</option>
${[...currentMembers].sort().map(u => `<option value="${escapeHtml(u)}">${escapeHtml(u)}</option>`).join('')}
</select>
<div class="collab-hint">Transferring will hand over all edit rights. You stay a member.</div>`
: `<div class="collab-readonly">${escapeHtml(bucket.owner_username)}</div>`
}
</div>
<div class="collab-field">
<span class="collab-label">Members (${peers.length} available)</span>
${isOwner
? `<div class="collab-members" id="__cb_edit_members">
${peers.map(u => {
const checked = currentMembers.has(u) ? 'checked' : '';
return `<label class="collab-member-row"><input type="checkbox" value="${escapeHtml(u)}" ${checked}><span>${escapeHtml(u)}</span></label>`;
}).join('') || '<div class="collab-members-empty">no other relay users</div>'}
</div>`
: `<div class="collab-members">
${(bucket.members || []).map(u => `<div class="collab-member-row"><span>${escapeHtml(u)}${u === self ? ' (you)' : ''}</span></div>`).join('')}
</div>
<div class="collab-hint">Only the owner can change members or transfer ownership.</div>`
}
</div>
</div>
<div class="collab-modal-footer">
<div class="collab-modal-status" id="__cb_status"></div>
<div style="flex:1"></div>
<button class="collab-btn-cancel" type="button">${isOwner ? 'Cancel' : 'Close'}</button>
${isOwner ? `<button class="collab-btn-submit" type="button">Save</button>` : ''}
</div>
</div>
`;
document.body.appendChild(overlay);
const done = (changed) => { overlay.remove(); resolve(changed); };
overlay.querySelector('.collab-modal-close').addEventListener('click', () => done(false));
overlay.querySelector('.collab-btn-cancel').addEventListener('click', () => done(false));
overlay.addEventListener('click', (e) => { if (e.target === overlay) done(false); });
document.addEventListener('keydown', function esc(e) {
if (e.key === 'Escape') { done(false); document.removeEventListener('keydown', esc); }
});
if (!isOwner) return; // read-only view; no submit handler
overlay.querySelector('.collab-btn-submit').addEventListener('click', async () => {
const newOwner = overlay.querySelector('#__cb_owner').value;
const members = [...overlay.querySelectorAll('#__cb_edit_members input:checked')].map(c => c.value);
const status = overlay.querySelector('#__cb_status');
const payload = { members };
if (newOwner && newOwner !== self) payload.owner = newOwner;
status.textContent = 'saving…';
status.style.color = 'var(--t-dim)';
try {
const res = await hyprcrd.api('api/collab-buckets/' + encodeURIComponent(collabUid), {
method: 'PATCH', body: payload,
});
if (!res?.ok) { status.textContent = res?.error || 'failed'; status.style.color = 'var(--t-danger)'; return; }
await pollOnce();
await loadCollabMeta();
done(true);
} catch (err) {
status.textContent = String(err?.message || err);
status.style.color = 'var(--t-danger)';
}
});
});
}
window._collabBucketsEditDialog = editBucketDialog;
/**
* Upgrade dialog for turning an existing local bucket into a collab
* bucket. The core bucket context menu shows "Make collaborative…" for
* non-collab buckets when the plugin is loaded. The dialog just picks
* members — name/color come from the existing bucket and are reused.
* Returns true on success so core can reload.
*/
async function upgradeBucketDialog(localBucketId) {
const bucket = window.state?.buckets?.find(b => String(b.id) === String(localBucketId));
if (!bucket || bucket.collab_uid) return false;
const relay = await fetchRelayTenants();
if (!relay) {
await hyprcrd.dialog.show({
title: 'collab buckets',
message: 'Could not reach the relay. Check the relay settings first.',
okLabel: 'Ok', cancelLabel: '',
});
return false;
}
const self = relay.self || '';
const peers = (relay.tenants || []).map(t => typeof t === 'string' ? t : (t.username || '')).filter(u => u && u !== self);
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'collab-modal-overlay';
overlay.innerHTML = `
<div class="collab-modal">
<div class="collab-modal-header">
<i class="ph ph-users-three text-t-accent"></i>
<span class="font-pixel text-xs text-t-accent">make "${escapeHtml(bucket.name)}" collaborative</span>
<div style="flex:1"></div>
<button class="collab-modal-close" type="button" aria-label="close"><i class="ph ph-x text-sm"></i></button>
</div>
<div class="collab-modal-body">
<div class="collab-field">
<div class="collab-hint">This bucket and all its cards will be shared with the members you pick. Existing cards are pushed on the next sync (~30s). You stay the owner.</div>
</div>
<div class="collab-field">
<span class="collab-label">Members (${peers.length} available)</span>
${peers.length === 0
? '<div class="collab-members-empty">no other relay users</div>'
: `<div class="collab-members" id="__cb_up_members">
${peers.map(u => `<label class="collab-member-row"><input type="checkbox" value="${escapeHtml(u)}"><span>${escapeHtml(u)}</span></label>`).join('')}
</div>`
}
</div>
</div>
<div class="collab-modal-footer">
<div class="collab-modal-status" id="__cb_status"></div>
<div style="flex:1"></div>
<button class="collab-btn-cancel" type="button">Cancel</button>
<button class="collab-btn-submit" type="button">Make collaborative</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const done = (changed) => { overlay.remove(); resolve(changed); };
overlay.querySelector('.collab-modal-close').addEventListener('click', () => done(false));
overlay.querySelector('.collab-btn-cancel').addEventListener('click', () => done(false));
overlay.addEventListener('click', (e) => { if (e.target === overlay) done(false); });
document.addEventListener('keydown', function esc(e) {
if (e.key === 'Escape') { done(false); document.removeEventListener('keydown', esc); }
});
overlay.querySelector('.collab-btn-submit').addEventListener('click', async () => {
const status = overlay.querySelector('#__cb_status');
const members = [...overlay.querySelectorAll('#__cb_up_members input:checked')].map(c => c.value);
status.textContent = 'upgrading…';
status.style.color = 'var(--t-dim)';
try {
const res = await hyprcrd.api('api/collab-buckets/upgrade', {
method: 'POST',
body: { bucket_id: localBucketId, members },
});
if (!res?.ok) { status.textContent = res?.error || 'failed'; status.style.color = 'var(--t-danger)'; return; }
await pollOnce();
await loadCollabMeta();
done(true);
} catch (err) {
status.textContent = String(err?.message || err);
status.style.color = 'var(--t-danger)';
}
});
});
}
window._collabBucketsUpgradeDialog = upgradeBucketDialog;
// Expose helper so the core bucket context menu can route collab-bucket
// rename / color / delete through the plugin endpoints.
window._collabBucketsPatch = async function (collabUid, payload) {
return hyprcrd.api('api/collab-buckets/' + encodeURIComponent(collabUid), { method: 'PATCH', body: payload });
};
window._collabBucketsDelete = async function (collabUid) {
return hyprcrd.api('api/collab-buckets/' + encodeURIComponent(collabUid), { method: 'DELETE' });
};
// ── Phase 4: board-card lock decoration ───────────────────────────
/**
* Walk every rendered board card and add / remove a lock indicator if
* another user currently holds an edit lock on it. Idempotent — safe to
* call on every render / lock-poll.
*/
function decorateBoardLocks() {
const board = document.getElementById('board');
if (!board || !window.state?.cards) return;
// Build card_id -> card row lookup so we can resolve relay_uid
const byId = new Map();
for (const c of window.state.cards) byId.set(String(c.id), c);
const self = window._collabSelf || null;
for (const el of board.querySelectorAll('.board-card[data-id]')) {
const card = byId.get(el.dataset.id);
const lock = card && card.relay_uid ? collabLocks.get(card.relay_uid) : null;
// Ignore our own lock — no point showing the user their own editing state
const foreign = lock && lock.holder_username && lock.holder_username !== self;
el.classList.toggle('collab-locked', !!foreign);
let badge = el.querySelector('.collab-lock-badge');
if (foreign) {
if (!badge) {
badge = document.createElement('div');
badge.className = 'collab-lock-badge';
badge.innerHTML = '<i class="ph ph-lock-simple text-xs"></i>';
el.appendChild(badge);
}
badge.title = `Being edited by ${lock.holder_username}`;
} else if (badge) {
badge.remove();
}
}
}
// Re-decorate whenever the board re-renders (reuses the existing #board
// MutationObserver pattern).
function setupBoardObserver() {
const board = document.getElementById('board');
if (!board) return;
const obs = new MutationObserver(() => decorateBoardLocks());
obs.observe(board, { childList: true, subtree: false });
}
// ── Phase 4: heartbeat the lock while editing a collab card ───────
/**
* Resolve a card to its collab bucket_uid + relay_uid. Returns null for
* non-collab cards (which don't get a lock).
*/
function collabIdentityOf(card) {
if (!card || !card.bucket_id) return null;
const bucket = window.state?.buckets?.find(b => String(b.id) === String(card.bucket_id));
if (!bucket?.collab_uid) return null;
if (!card.relay_uid) return null; // not yet pushed → no lock
return { bucket_uid: bucket.collab_uid, card_uid: card.relay_uid };
}
async function acquireLock(bucket_uid, card_uid) {
try {
await hyprcrd.api('api/collab-locks', {
method: 'POST',
body: { bucket_uid, card_uid },
});
} catch { /* silent */ }
}
async function releaseLock(bucket_uid, card_uid) {
try {
await hyprcrd.api(
'api/collab-locks/' + encodeURIComponent(bucket_uid) + '/' + encodeURIComponent(card_uid),
{ method: 'DELETE' },
);
} catch { /* silent */ }
}
function startHeartbeat(bucket_uid, card_uid) {
stopHeartbeat();
activeLock = { bucket_uid, card_uid, timer: null };
acquireLock(bucket_uid, card_uid);
activeLock.timer = setInterval(() => acquireLock(bucket_uid, card_uid), LOCK_HEARTBEAT_MS);
}
function stopHeartbeat() {
if (!activeLock) return;
const { bucket_uid, card_uid, timer } = activeLock;
if (timer) clearInterval(timer);
releaseLock(bucket_uid, card_uid);
activeLock = null;
}
window.addEventListener('hyprcrd:editor-open', (e) => {
const card = e.detail?.card;
const id = collabIdentityOf(card);
if (id) startHeartbeat(id.bucket_uid, id.card_uid);
});
window.addEventListener('hyprcrd:editor-close', () => {
stopHeartbeat();
});
// Graceful release on tab close
window.addEventListener('beforeunload', () => {
if (activeLock) {
// Best-effort sync release; browsers may block regular fetch, so
// use sendBeacon when available.
try {
if (navigator.sendBeacon) {
const url = 'api/collab-locks/' + encodeURIComponent(activeLock.bucket_uid) + '/' + encodeURIComponent(activeLock.card_uid);
// sendBeacon only supports POST; use fetch keepalive as the
// DELETE fallback. Both are best-effort at unload time.
fetch(url, { method: 'DELETE', keepalive: true }).catch(() => {});
}
} catch { /* ignore */ }
}
});
// ── Init ──────────────────────────────────────────────────────────
async function init() {
// Wait for #bucket-bar to exist (core renders it on startup)
if (!document.getElementById('bucket-bar')) {
setTimeout(init, 100);
return;
}
setupBarObserver();
setupClickInterceptor();
setupBoardObserver();
await loadCollabMeta();
await pollOnce();
await loadCollabMeta();
decorateBucketBar();
decorateBoardLocks();
startPolling();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
hyprcrd.registerPlugin?.({
name: 'collab-buckets',
version: '1.1.0',
description: 'Collaborative buckets shared via the relay. First-class rows in the bucket bar with unread pulse/dot.',
});
+10
View File
@@ -0,0 +1,10 @@
{
"name": "collab-buckets",
"version": "1.0.0",
"description": "Collaborative buckets shared between relay tenants. Polls the relay for bucket updates and shows pulsing header tiles with unread indicators.",
"frontend": {
"js": ["collab-buckets.js"],
"css": ["collab-buckets.css"]
},
"backend": "backend.php"
}
+62
View File
@@ -181,6 +181,64 @@ $db->exec('CREATE TABLE IF NOT EXISTS chat_messages (
)');
$db->exec('CREATE INDEX IF NOT EXISTS idx_chat_recipient ON chat_messages(recipient_id, fetched, id)');
// Collaborative buckets: shared among multiple tenants. Each bucket has one
// owner and a membership set. Buckets persist (not ephemeral) since they are
// the container for ongoing collaboration — cleanup only when deleted.
$db->exec('CREATE TABLE IF NOT EXISTS collab_buckets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT UNIQUE NOT NULL,
owner_id INTEGER NOT NULL,
owner_username TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT "#38bdf8",
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (owner_id) REFERENCES tenants(id) ON DELETE CASCADE
)');
$db->exec('CREATE TABLE IF NOT EXISTS collab_bucket_members (
bucket_uid TEXT NOT NULL,
tenant_id INTEGER NOT NULL,
username TEXT NOT NULL,
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (bucket_uid, tenant_id),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
)');
$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_member_tenant ON collab_bucket_members(tenant_id, bucket_uid)');
$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_bucket_updated ON collab_buckets(updated_at)');
// Collab cards: the actual synced content, stored at-rest under the
// inter-tenant key. Payload is an encrypted JSON blob containing title,
// body, color, tags, due_date, position. Tombstoned rows (deleted=1)
// survive for 7 days so late pollers see the deletion.
$db->exec('CREATE TABLE IF NOT EXISTS collab_cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bucket_uid TEXT NOT NULL,
card_uid TEXT NOT NULL,
author_username TEXT NOT NULL,
payload TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted INTEGER NOT NULL DEFAULT 0,
UNIQUE (bucket_uid, card_uid)
)');
$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_cards_bucket_updated ON collab_cards(bucket_uid, updated_at)');
// Phase 4: advisory edit locks on collab cards. A client that opens the
// editor for a collab card heartbeats into this table every 30s with a
// 60s TTL. Other clients poll the list and show a lock indicator on the
// card. The lock is purely advisory — force-editing is still allowed and
// LWW remains the authoritative conflict resolver.
$db->exec('CREATE TABLE IF NOT EXISTS collab_card_locks (
bucket_uid TEXT NOT NULL,
card_uid TEXT NOT NULL,
holder_id INTEGER NOT NULL,
holder_username TEXT NOT NULL,
acquired_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TEXT NOT NULL,
PRIMARY KEY (bucket_uid, card_uid, holder_id),
FOREIGN KEY (holder_id) REFERENCES tenants(id) ON DELETE CASCADE
)');
$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_card_locks_expires ON collab_card_locks(expires_at)');
$db->exec('CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
tenant_id INTEGER NOT NULL,
@@ -200,6 +258,10 @@ $db->exec("DELETE FROM messages WHERE created_at < datetime('now', '-24 hours')"
$db->exec("DELETE FROM attachments WHERE created_at < datetime('now', '-24 hours')");
// Chat: 7 days, longer than card sync but still ephemeral
$db->exec("DELETE FROM chat_messages WHERE created_at < datetime('now', '-7 days')");
// Collab-card tombstones: 7 days so late pollers still see the delete.
$db->exec("DELETE FROM collab_cards WHERE deleted = 1 AND updated_at < datetime('now', '-7 days')");
// Collab-card locks: drop everything past its TTL on every request.
$db->exec("DELETE FROM collab_card_locks WHERE expires_at < datetime('now')");
$db->exec("DELETE FROM sessions WHERE expires_at < datetime('now')");
$db->exec("DELETE FROM rate_limits WHERE attempted_at < datetime('now', '-5 minutes')");
+166
View File
@@ -0,0 +1,166 @@
<?php
// Collab bucket routes:
// POST /api/collab-buckets create {uid, name, color, members: [usernames]}
// GET /api/collab-buckets list buckets where the caller is a member
// PATCH /api/collab-buckets/{uid} owner-only: update name / color / members
// DELETE /api/collab-buckets/{uid} owner: delete bucket; non-owner: leave
//
// No body encryption on buckets — bucket metadata (name, color) is lightweight
// shared data. Cards inside the buckets (Phase 3) follow the chat decrypt-
// reencrypt-per-recipient pattern so content stays private on the relay.
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
$clientTenant = $clientTenant ?? authClient($db);
// ── POST: create ──────────────────────────────────────────────────
if ($route === '/api/collab-buckets' && $method === 'POST') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$uid = trim((string)($input['uid'] ?? ''));
$name = trim((string)($input['name'] ?? ''));
$color = (string)($input['color'] ?? '#38bdf8');
$memberUsernames = is_array($input['members'] ?? null) ? $input['members'] : [];
if ($uid === '' || $name === '') jsonResponse(['error' => 'uid and name required'], 400);
// Prevent collisions (clients generate UUIDs so this is paranoia)
$exists = $db->prepare('SELECT 1 FROM collab_buckets WHERE uid = :u');
$exists->execute(['u' => $uid]);
if ($exists->fetchColumn()) jsonResponse(['error' => 'uid already exists'], 409);
// Resolve requested members; silently drop unknown usernames
$resolved = [];
foreach ($memberUsernames as $u) {
$u = trim((string)$u);
if ($u === '') continue;
$t = getTenantByUsername($db, $u);
if ($t) $resolved[(int)$t['id']] = $t['username'];
}
// Always include the owner
$resolved[(int)$clientTenant['id']] = $clientTenant['username'];
try {
$db->beginTransaction();
$db->prepare('INSERT INTO collab_buckets (uid, owner_id, owner_username, name, color) VALUES (:uid, :oid, :ou, :n, :c)')
->execute(['uid' => $uid, 'oid' => $clientTenant['id'], 'ou' => $clientTenant['username'], 'n' => $name, 'c' => $color]);
$mstmt = $db->prepare('INSERT INTO collab_bucket_members (bucket_uid, tenant_id, username) VALUES (:b, :t, :u)');
foreach ($resolved as $tid => $uname) {
$mstmt->execute(['b' => $uid, 't' => $tid, 'u' => $uname]);
}
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction()) $db->rollBack();
jsonResponse(['error' => 'create failed: ' . $e->getMessage()], 500);
}
jsonResponse(['ok' => true, 'uid' => $uid, 'members' => array_values($resolved)]);
}
// ── GET: list buckets I'm a member of ─────────────────────────────
if ($route === '/api/collab-buckets' && $method === 'GET') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
if (isRateLimited($db, 'collab-buckets-get', 120)) jsonResponse(['error' => 'rate limited'], 429);
$stmt = $db->prepare('
SELECT b.uid, b.name, b.color, b.owner_username, b.created_at, b.updated_at
FROM collab_buckets b
INNER JOIN collab_bucket_members m ON m.bucket_uid = b.uid
WHERE m.tenant_id = :tid
ORDER BY b.updated_at DESC
');
$stmt->execute(['tid' => $clientTenant['id']]);
$buckets = $stmt->fetchAll();
// Attach member list to each bucket
$mstmt = $db->prepare('SELECT username FROM collab_bucket_members WHERE bucket_uid = :uid ORDER BY username');
foreach ($buckets as &$b) {
$mstmt->execute(['uid' => $b['uid']]);
$b['members'] = array_column($mstmt->fetchAll(), 'username');
}
jsonResponse(['ok' => true, 'buckets' => $buckets]);
}
// ── PATCH: owner updates name / color / members ───────────────────
if (preg_match('#^/api/collab-buckets/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'PATCH') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
$uid = $m[1];
$row = $db->prepare('SELECT owner_id FROM collab_buckets WHERE uid = :u');
$row->execute(['u' => $uid]);
$bucket = $row->fetch();
if (!$bucket) jsonResponse(['error' => 'bucket not found'], 404);
if ((int)$bucket['owner_id'] !== (int)$clientTenant['id']) jsonResponse(['error' => 'only the owner can update'], 403);
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$sets = [];
$params = ['u' => $uid];
if (isset($input['name'])) { $sets[] = 'name = :n'; $params['n'] = trim((string)$input['name']); }
if (isset($input['color'])) { $sets[] = 'color = :c'; $params['c'] = (string)$input['color']; }
// Ownership transfer: new owner must already be a member of the bucket.
// We set owner_id/owner_username atomically and keep the current
// caller in the member list (no implicit leave on transfer).
if (isset($input['owner'])) {
$newOwnerName = trim((string)$input['owner']);
if ($newOwnerName === '') jsonResponse(['error' => 'owner cannot be empty'], 400);
$newOwner = getTenantByUsername($db, $newOwnerName);
if (!$newOwner) jsonResponse(['error' => "no such user: $newOwnerName"], 404);
$memCheck = $db->prepare('SELECT 1 FROM collab_bucket_members WHERE bucket_uid = :b AND tenant_id = :t');
$memCheck->execute(['b' => $uid, 't' => $newOwner['id']]);
if (!$memCheck->fetchColumn()) {
jsonResponse(['error' => 'new owner must already be a member of the bucket'], 400);
}
$sets[] = 'owner_id = :oid'; $params['oid'] = (int)$newOwner['id'];
$sets[] = 'owner_username = :oun'; $params['oun'] = (string)$newOwner['username'];
}
if ($sets) {
$sets[] = "updated_at = datetime('now')";
$db->prepare('UPDATE collab_buckets SET ' . implode(', ', $sets) . ' WHERE uid = :u')->execute($params);
}
if (isset($input['members']) && is_array($input['members'])) {
$resolved = [];
foreach ($input['members'] as $u) {
$u = trim((string)$u);
if ($u === '') continue;
$t = getTenantByUsername($db, $u);
if ($t) $resolved[(int)$t['id']] = $t['username'];
}
// Caller is always kept (the user editing) + the current effective
// owner (possibly just transferred in this same request).
$resolved[(int)$clientTenant['id']] = $clientTenant['username'];
if (isset($newOwner)) $resolved[(int)$newOwner['id']] = $newOwner['username'];
$db->prepare('DELETE FROM collab_bucket_members WHERE bucket_uid = :u')->execute(['u' => $uid]);
$ins = $db->prepare('INSERT INTO collab_bucket_members (bucket_uid, tenant_id, username) VALUES (:b, :t, :u)');
foreach ($resolved as $tid => $uname) {
$ins->execute(['b' => $uid, 't' => $tid, 'u' => $uname]);
}
$db->prepare("UPDATE collab_buckets SET updated_at = datetime('now') WHERE uid = :u")->execute(['u' => $uid]);
}
jsonResponse(['ok' => true]);
}
// ── DELETE: owner wipes; member leaves ────────────────────────────
if (preg_match('#^/api/collab-buckets/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
$uid = $m[1];
$row = $db->prepare('SELECT owner_id FROM collab_buckets WHERE uid = :u');
$row->execute(['u' => $uid]);
$bucket = $row->fetch();
if (!$bucket) jsonResponse(['error' => 'bucket not found'], 404);
if ((int)$bucket['owner_id'] === (int)$clientTenant['id']) {
// Owner: destroy everything
$db->prepare('DELETE FROM collab_bucket_members WHERE bucket_uid = :u')->execute(['u' => $uid]);
$db->prepare('DELETE FROM collab_buckets WHERE uid = :u')->execute(['u' => $uid]);
jsonResponse(['ok' => true, 'action' => 'deleted']);
} else {
// Member: remove own membership only
$del = $db->prepare('DELETE FROM collab_bucket_members WHERE bucket_uid = :u AND tenant_id = :t');
$del->execute(['u' => $uid, 't' => $clientTenant['id']]);
// Bump updated_at so remaining members see the member-list change on next poll
$db->prepare("UPDATE collab_buckets SET updated_at = datetime('now') WHERE uid = :u")->execute(['u' => $uid]);
jsonResponse(['ok' => true, 'action' => 'left']);
}
}
+167
View File
@@ -0,0 +1,167 @@
<?php
// Collab card routes (Phase 3):
// POST /api/collab-cards upsert a card in a collab bucket
// GET /api/collab-cards?since=<ts> pull deltas since a timestamp
// DELETE /api/collab-cards/{bucket}/{card} tombstone a card
//
// Encryption hop (same pattern as chat.php):
// POST: arrives encrypted with the sender's client secret → relay
// decrypts with that secret (via masterDecrypt of their
// secret_encrypted) → re-encrypts under the inter-tenant key →
// stored at rest.
// GET: for each row visible to the caller, decrypt from inter-tenant
// key → re-encrypt with the recipient's client secret → return.
//
// Membership check: every operation requires the caller to be a current
// member of the bucket_uid referenced. Drops silently when the caller
// leaves or is kicked.
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
$clientTenant = $clientTenant ?? authClient($db);
/** Is $tenantId a member of $bucketUid right now? */
function collabCardsIsMember(PDO $db, string $bucketUid, int $tenantId): bool {
$stmt = $db->prepare('SELECT 1 FROM collab_bucket_members WHERE bucket_uid = :b AND tenant_id = :t');
$stmt->execute(['b' => $bucketUid, 't' => $tenantId]);
return (bool)$stmt->fetchColumn();
}
// ── POST: upsert a card ──────────────────────────────────────────
if ($route === '/api/collab-cards' && $method === 'POST') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
if (isRateLimited($db, 'collab-cards-post', 240)) jsonResponse(['error' => 'rate limited'], 429);
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$bucketUid = trim((string)($input['bucket_uid'] ?? ''));
$cardUid = trim((string)($input['card_uid'] ?? ''));
$encEnvelope = (string)($input['payload'] ?? '');
if ($bucketUid === '' || $cardUid === '' || $encEnvelope === '') {
jsonResponse(['error' => 'bucket_uid, card_uid, payload required'], 400);
}
if (!collabCardsIsMember($db, $bucketUid, (int)$clientTenant['id'])) {
jsonResponse(['error' => 'not a member of this bucket'], 403);
}
if (empty($clientTenant['secret_encrypted'])) {
jsonResponse(['error' => 'sender has no client secret set — log into the relay UI and configure one in your account'], 400);
}
$senderSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY);
if (!$senderSecret) jsonResponse(['error' => 'sender secret unavailable'], 500);
$interKey = getInterTenantKey($db, $MASTER_KEY);
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set — admin must complete setup'], 500);
$plain = clientDecrypt($encEnvelope, $senderSecret);
if ($plain === '') jsonResponse(['error' => 'payload decrypt failed (sender secret mismatch?)'], 400);
$stored = clientEncrypt($plain, $interKey);
// LWW: upsert with server-generated updated_at so clients all agree on
// the authoritative order (client clocks drift, server clock is the tie-
// breaker). Last writer wins.
$db->prepare('
INSERT INTO collab_cards (bucket_uid, card_uid, author_username, payload, updated_at, deleted)
VALUES (:b, :c, :a, :p, datetime(\'now\'), 0)
ON CONFLICT(bucket_uid, card_uid) DO UPDATE SET
author_username = excluded.author_username,
payload = excluded.payload,
updated_at = excluded.updated_at,
deleted = 0
')->execute([
'b' => $bucketUid,
'c' => $cardUid,
'a' => $clientTenant['username'],
'p' => $stored,
]);
// Return the row's updated_at so the client can track what it pushed
$row = $db->prepare('SELECT updated_at FROM collab_cards WHERE bucket_uid = :b AND card_uid = :c');
$row->execute(['b' => $bucketUid, 'c' => $cardUid]);
jsonResponse(['ok' => true, 'updated_at' => $row->fetchColumn()]);
}
// ── GET: pull deltas since a given timestamp ─────────────────────
if ($route === '/api/collab-cards' && $method === 'GET') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
if (isRateLimited($db, 'collab-cards-get', 120)) jsonResponse(['error' => 'rate limited'], 429);
if (empty($clientTenant['secret_encrypted'])) {
jsonResponse(['error' => 'no client secret set — log into the relay UI and configure one in your account'], 400);
}
$since = trim((string)($_GET['since'] ?? ''));
$recipientSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY);
if (!$recipientSecret) jsonResponse(['error' => 'recipient secret unavailable'], 500);
$interKey = getInterTenantKey($db, $MASTER_KEY);
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
// Join to membership so only buckets the caller is in right now are returned
$sql = '
SELECT c.bucket_uid, c.card_uid, c.author_username, c.payload, c.updated_at, c.deleted
FROM collab_cards c
INNER JOIN collab_bucket_members m ON m.bucket_uid = c.bucket_uid
WHERE m.tenant_id = :tid
';
$params = ['tid' => $clientTenant['id']];
if ($since !== '') {
$sql .= ' AND c.updated_at > :since';
$params['since'] = $since;
}
$sql .= ' ORDER BY c.updated_at ASC LIMIT 1000';
$stmt = $db->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll();
$out = [];
$maxTs = $since;
foreach ($rows as $row) {
// Tombstones don't need payload — they just signal "delete locally"
if ((int)$row['deleted'] === 1) {
$out[] = [
'bucket_uid' => $row['bucket_uid'],
'card_uid' => $row['card_uid'],
'author_username' => $row['author_username'],
'updated_at' => $row['updated_at'],
'deleted' => true,
];
} else {
$plain = clientDecrypt($row['payload'] ?? '', $interKey);
$reEncrypted = clientEncrypt($plain, $recipientSecret);
$out[] = [
'bucket_uid' => $row['bucket_uid'],
'card_uid' => $row['card_uid'],
'author_username' => $row['author_username'],
'payload' => $reEncrypted,
'updated_at' => $row['updated_at'],
'deleted' => false,
];
}
if ($row['updated_at'] > $maxTs) $maxTs = $row['updated_at'];
}
jsonResponse(['ok' => true, 'cards' => $out, 'max_updated_at' => $maxTs]);
}
// ── DELETE: tombstone a card ─────────────────────────────────────
if (preg_match('#^/api/collab-cards/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
$bucketUid = $m[1];
$cardUid = $m[2];
if (!collabCardsIsMember($db, $bucketUid, (int)$clientTenant['id'])) {
jsonResponse(['error' => 'not a member of this bucket'], 403);
}
// Mark as tombstone rather than actually deleting. Late pollers see it,
// bootstrap.php cleanup prunes after 7 days.
$db->prepare('
INSERT INTO collab_cards (bucket_uid, card_uid, author_username, payload, updated_at, deleted)
VALUES (:b, :c, :a, \'\', datetime(\'now\'), 1)
ON CONFLICT(bucket_uid, card_uid) DO UPDATE SET
author_username = excluded.author_username,
payload = \'\',
updated_at = datetime(\'now\'),
deleted = 1
')->execute([
'b' => $bucketUid,
'c' => $cardUid,
'a' => $clientTenant['username'],
]);
jsonResponse(['ok' => true]);
}
+75
View File
@@ -0,0 +1,75 @@
<?php
// Collab card lock routes (Phase 4 — advisory edit locks):
// POST /api/collab-locks acquire/refresh a lock
// DELETE /api/collab-locks/{bucket}/{card} release own lock
// GET /api/collab-locks list locks visible to caller
//
// Membership is enforced on every operation. A lock refresh just extends
// the TTL (60 seconds from now). Expired rows are pruned on every request
// via bootstrap.php cleanup, so the GET list never contains stale locks.
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
$clientTenant = $clientTenant ?? authClient($db);
function collabLocksIsMember(PDO $db, string $bucketUid, int $tenantId): bool {
$stmt = $db->prepare('SELECT 1 FROM collab_bucket_members WHERE bucket_uid = :b AND tenant_id = :t');
$stmt->execute(['b' => $bucketUid, 't' => $tenantId]);
return (bool)$stmt->fetchColumn();
}
// ── POST: acquire/refresh ────────────────────────────────────────
if ($route === '/api/collab-locks' && $method === 'POST') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
if (isRateLimited($db, 'collab-locks-post', 240)) jsonResponse(['error' => 'rate limited'], 429);
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$bucketUid = trim((string)($input['bucket_uid'] ?? ''));
$cardUid = trim((string)($input['card_uid'] ?? ''));
if ($bucketUid === '' || $cardUid === '') jsonResponse(['error' => 'bucket_uid and card_uid required'], 400);
if (!collabLocksIsMember($db, $bucketUid, (int)$clientTenant['id'])) {
jsonResponse(['error' => 'not a member of this bucket'], 403);
}
$db->prepare("
INSERT INTO collab_card_locks (bucket_uid, card_uid, holder_id, holder_username, acquired_at, expires_at)
VALUES (:b, :c, :hid, :hu, datetime('now'), datetime('now', '+60 seconds'))
ON CONFLICT(bucket_uid, card_uid, holder_id) DO UPDATE SET
expires_at = datetime('now', '+60 seconds')
")->execute([
'b' => $bucketUid,
'c' => $cardUid,
'hid' => $clientTenant['id'],
'hu' => $clientTenant['username'],
]);
jsonResponse(['ok' => true, 'expires_at' => (string)$db->query("SELECT datetime('now', '+60 seconds')")->fetchColumn()]);
}
// ── DELETE: release own lock ─────────────────────────────────────
if (preg_match('#^/api/collab-locks/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
$bucketUid = $m[1];
$cardUid = $m[2];
$db->prepare('DELETE FROM collab_card_locks WHERE bucket_uid = :b AND card_uid = :c AND holder_id = :h')
->execute(['b' => $bucketUid, 'c' => $cardUid, 'h' => $clientTenant['id']]);
jsonResponse(['ok' => true]);
}
// ── GET: list locks visible to caller ────────────────────────────
if ($route === '/api/collab-locks' && $method === 'GET') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
if (isRateLimited($db, 'collab-locks-get', 120)) jsonResponse(['error' => 'rate limited'], 429);
// Only return locks in buckets the caller is currently a member of,
// and only non-expired rows (bootstrap cleanup + defensive filter).
$stmt = $db->prepare("
SELECT l.bucket_uid, l.card_uid, l.holder_id, l.holder_username, l.acquired_at, l.expires_at
FROM collab_card_locks l
INNER JOIN collab_bucket_members m ON m.bucket_uid = l.bucket_uid
WHERE m.tenant_id = :tid
AND l.expires_at > datetime('now')
ORDER BY l.acquired_at DESC
");
$stmt->execute(['tid' => $clientTenant['id']]);
jsonResponse(['ok' => true, 'locks' => $stmt->fetchAll()]);
}
+3
View File
@@ -62,6 +62,9 @@ if (str_starts_with($route, '/api/')) {
require __DIR__ . '/inc/routes/admin.php';
require __DIR__ . '/inc/routes/messages.php';
require __DIR__ . '/inc/routes/chat.php';
require __DIR__ . '/inc/routes/collab-buckets.php';
require __DIR__ . '/inc/routes/collab-cards.php';
require __DIR__ . '/inc/routes/collab-locks.php';
jsonResponse(['error' => 'not found'], 404);
}
+5
View File
@@ -139,5 +139,10 @@ class Database
if (!in_array('color', $bcolNames)) {
self::$pdo->exec("ALTER TABLE buckets ADD COLUMN color TEXT NOT NULL DEFAULT '#64748b'");
}
// Collaborative buckets: plugin-managed relay UID. NULL for local buckets.
if (!in_array('collab_uid', $bcolNames)) {
self::$pdo->exec('ALTER TABLE buckets ADD COLUMN collab_uid TEXT DEFAULT NULL');
self::$pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_buckets_collab_uid ON buckets(collab_uid) WHERE collab_uid IS NOT NULL');
}
}
}