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

+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() {