---
config.php | 2 +-
public/index.php | 12 +--
public/js/modules/editor.js | 78 ++++++++++++++++++-
public/plugins/collab-buckets/backend.php | 9 ++-
.../plugins/tests/card-interactions.test.js | 29 +++++--
public/plugins/tests/runner.js | 43 +++++++++-
public/plugins/tests/undo.test.js | 15 +++-
src/CalDavSync.php | 13 ++++
8 files changed, 178 insertions(+), 23 deletions(-)
diff --git a/config.php b/config.php
index a11f0c6..3f6655f 100644
--- a/config.php
+++ b/config.php
@@ -1,7 +1,7 @@
'title is required']); exit; }
$pos = $input['position'] ?? 0;
$body = $input['body'] ?? '';
- $db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position, tags) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position, :tags)')->execute([
+ $db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position, tags, due_date, scratchpad) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position, :tags, :due_date, :scratchpad)')->execute([
'title' => $title, 'body' => $body, 'color' => validColor($input['color'] ?? null),
'bucket_id' => validInt($input['bucket_id'] ?? null, null),
'parent_id' => validInt($input['parent_id'] ?? null, null),
'position' => validInt($pos),
'tags' => extractTags($body),
+ 'due_date' => $input['due_date'] ?? null,
+ 'scratchpad' => $input['scratchpad'] ?? null,
]);
echo json_encode($db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . $db->lastInsertId())->fetch());
exit;
@@ -1276,7 +1278,7 @@ if (str_starts_with($route, '/api/')) {
@@ -2195,7 +2197,7 @@ if (str_starts_with($route, '/api/')) {
-
+
@@ -2260,7 +2262,7 @@ if (str_starts_with($route, '/api/')) {
@@ -2271,7 +2273,7 @@ if (str_starts_with($route, '/api/')) {
scratchpad
-
+
diff --git a/public/js/modules/editor.js b/public/js/modules/editor.js
index 1b1be2f..1df03a0 100644
--- a/public/js/modules/editor.js
+++ b/public/js/modules/editor.js
@@ -13,6 +13,8 @@ const inpTitle = $('inp-title');
const inpColor = $('inp-color');
const inpBucket = $('inp-bucket');
const presets = $('color-presets');
+const scratchpadPanel = $('editor-scratchpad');
+const scratchpadBody = $('scratchpad-body');
const linkAc = $('link-ac');
const tagAc = $('tag-ac');
@@ -176,6 +178,45 @@ function selectColor(hex) {
// ── Open / close ──
+// ── Autobackup (draft recovery) ──
+const DRAFT_KEY = 'hyprcrd-editor-draft';
+let draftSaveTimer = null;
+
+function draftKeyFor(cardId) {
+ return DRAFT_KEY + ':' + (cardId || 'new');
+}
+
+function loadDraft(cardId) {
+ try {
+ const raw = localStorage.getItem(draftKeyFor(cardId));
+ return raw ? JSON.parse(raw) : null;
+ } catch { return null; }
+}
+
+function saveDraftDebounced() {
+ clearTimeout(draftSaveTimer);
+ draftSaveTimer = setTimeout(() => {
+ const id = state.editingId || 'new';
+ const data = {
+ title: inpTitle.value,
+ body: body.value,
+ scratchpad: scratchpadBody?.value || '',
+ savedAt: Date.now(),
+ };
+ // Don't store empty drafts
+ if (!data.title.trim() && !data.body.trim() && !data.scratchpad.trim()) {
+ localStorage.removeItem(draftKeyFor(id));
+ return;
+ }
+ try { localStorage.setItem(draftKeyFor(id), JSON.stringify(data)); } catch {}
+ }, 800);
+}
+
+function clearDraft(cardId) {
+ clearTimeout(draftSaveTimer);
+ try { localStorage.removeItem(draftKeyFor(cardId || 'new')); } catch {}
+}
+
export function openModal(card = null) {
state.editingId = card ? card.id : null;
$('modal-title').textContent = card ? 'edit_card' : 'new_card';
@@ -195,15 +236,44 @@ export function openModal(card = null) {
if (linesVisible) updateGutter();
currentLine = -1;
updateActiveLine();
+
+ // Check for unsaved draft (newer than what we just loaded).
+ // Skipped during test runs — tests would block on the confirm dialog.
+ const draft = loadDraft(card?.id);
+ if (draft && !window._testRunning) {
+ const cardModified = card ? Date.parse(card.updated_at + 'Z') || 0 : 0;
+ const draftIsNewer = draft.savedAt > cardModified;
+ const draftHasContent = draft.title || draft.body || draft.scratchpad;
+ const differsFromLoaded = draft.title !== inpTitle.value || draft.body !== body.value || draft.scratchpad !== (scratchpadBody?.value || '');
+ if (draftIsNewer && draftHasContent && differsFromLoaded) {
+ const age = Math.round((Date.now() - draft.savedAt) / 60000);
+ const ageText = age < 1 ? 'just now' : age < 60 ? `${age} min ago` : `${Math.round(age / 60)} h ago`;
+ const restore = window.confirm(`Unsaved draft from ${ageText} found.\n\nRestore it?`);
+ if (restore) {
+ inpTitle.value = draft.title || '';
+ body.value = draft.body || '';
+ if (scratchpadBody) scratchpadBody.value = draft.scratchpad || '';
+ updatePreview();
+ } else {
+ clearDraft(card?.id);
+ }
+ }
+ } else if (draft && window._testRunning) {
+ // During tests, silently discard any draft so it doesn't accumulate
+ clearDraft(card?.id);
+ }
+
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);
+inpTitle.addEventListener('input', () => { updatePreview(); saveDraftDebounced(); });
inpTitle.addEventListener('keydown', e => {
if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); body.focus(); }
});
+body.addEventListener('input', saveDraftDebounced);
+scratchpadBody?.addEventListener('input', saveDraftDebounced);
// ── Line numbers ──
@@ -396,10 +466,12 @@ async function doSave(close, { animate = true } = {}) {
const data = { title: inpTitle.value, body: body.value, color: inpColor.value, bucket_id: inpBucket.value || null, due_date: inpDueValue?.value || null, scratchpad: scratch };
if (!data.title.trim()) return;
const returnToGallery = state.returnToGalleryAfterEdit;
+ const wasEditingId = state.editingId;
if (state.editingId) {
const prev = snapshotCard(state.cards.find(c => c.id == state.editingId));
if (prev) pushCardEdit('edit', state.editingId, { title: prev.title, body: prev.body, color: prev.color, bucket_id: prev.bucket_id, due_date: prev.due_date ?? null });
await api(`api/cards/${state.editingId}`, { method: 'PATCH', body: data });
+ clearDraft(state.editingId);
} else {
if (state.activeGroup) {
const parent = state.cards.find(c => c.id == state.activeGroup);
@@ -416,6 +488,7 @@ async function doSave(close, { animate = true } = {}) {
state._lastSavedId = created.id;
state.recentlyCreatedIds?.add(String(created.id));
}
+ clearDraft(null); // 'new' draft
}
await window._reloadAll();
// Auto-sync if card has #termin or #geburtstag
@@ -533,9 +606,6 @@ function applyEditorSplit() {
preview.style.flex = `${100 - editorSplit} 1 0`;
}
-const scratchpadPanel = $('editor-scratchpad');
-const scratchpadBody = $('scratchpad-body');
-
function showSidePanel(which) {
const showPreview = which === 'preview';
const showScratch = which === 'scratchpad';
diff --git a/public/plugins/collab-buckets/backend.php b/public/plugins/collab-buckets/backend.php
index 1dc5a94..f0b289d 100644
--- a/public/plugins/collab-buckets/backend.php
+++ b/public/plugins/collab-buckets/backend.php
@@ -411,9 +411,16 @@ function collabSyncCards(PDO $db): array {
$stats['moved']++;
if ($mv['card_missing'] || empty($mv['new_bucket_uid'])) {
- // Card is gone or moved out of collab entirely — forget about it
+ // Card is gone or moved out of collab entirely — forget about it.
+ // Critical: also clear the card's relay_uid so the incoming
+ // tombstone (echoed back from the relay on next pull) doesn't
+ // find this card and delete it locally too.
$db->prepare('DELETE FROM collab_card_state WHERE card_id = :id')
->execute(['id' => $mv['card_id']]);
+ if (!$mv['card_missing']) {
+ $db->prepare('UPDATE cards SET relay_uid = NULL WHERE 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
diff --git a/public/plugins/tests/card-interactions.test.js b/public/plugins/tests/card-interactions.test.js
index 52db7b4..d53ef17 100644
--- a/public/plugins/tests/card-interactions.test.js
+++ b/public/plugins/tests/card-interactions.test.js
@@ -157,14 +157,26 @@ describe('Card: Context Menu Actions', () => {
expect(doneBtn.textContent.toLowerCase()).toContain('mark done');
click(doneBtn);
- await sleep(300);
- let updated = await fetch(`api/cards?ids=${res.id}`).then(r => r.json());
+ // Poll until #done is persisted to the DB
+ let updated;
+ for (let i = 0; i < 40; i++) {
+ await sleep(100);
+ updated = await fetch(`api/cards?ids=${res.id}`).then(r => r.json());
+ if ((updated[0]?.tags || '').includes('done')) break;
+ }
expect((updated[0].body || '').toLowerCase()).toContain('#done');
expect(updated[0].tags || '').toContain('done');
+ // Wait for the board to re-render with the updated card data
+ for (let i = 0; i < 20; i++) {
+ const card = window.state?.cards?.find(c => c.id == res.id);
+ if (card && (card.tags || '').includes('done')) break;
+ await sleep(100);
+ }
+
// Re-open menu — the entry should now flip to "Unmark done"
rightClick(document.querySelector(`[data-id="${res.id}"]`));
- await sleep(150);
+ await sleep(200);
const doneBtn2 = $$('#ctx-items [data-action="due-done"]')[0];
expect(doneBtn2.textContent.toLowerCase()).toContain('unmark');
@@ -331,11 +343,16 @@ describe('Card: Group Delete', () => {
// Confirm the delete
const okBtn = document.getElementById('dialog-ok');
if (okBtn) click(okBtn);
- await sleep(500);
}
- // All three cards should be gone
- const remaining = await fetch('api/cards').then(r => r.json());
+ // Poll until all three cards are gone (group delete = N serial DELETEs)
+ let remaining;
+ for (let i = 0; i < 40; i++) {
+ await sleep(100);
+ remaining = await fetch('api/cards').then(r => r.json());
+ const stillThere = remaining.some(c => c.id === parent.id || c.id === child1.id || c.id === child2.id);
+ if (!stillThere) break;
+ }
expect(remaining.some(c => c.id === parent.id)).toBe(false);
expect(remaining.some(c => c.id === child1.id)).toBe(false);
expect(remaining.some(c => c.id === child2.id)).toBe(false);
diff --git a/public/plugins/tests/runner.js b/public/plugins/tests/runner.js
index 3dadef9..bf2f021 100644
--- a/public/plugins/tests/runner.js
+++ b/public/plugins/tests/runner.js
@@ -201,6 +201,37 @@ async function createBackup() {
backupBlob = await res.blob();
}
+// Purge leftover test artifacts from previous failed runs.
+// Tests use `__prefix__` naming; anything matching gets deleted before
+// the baseline backup so it doesn't get restored after the run either.
+async function purgeTestLeftovers() {
+ const token = () => document.cookie.match(/csrf=([^;]+)/)?.[1] || getCsrf();
+ let purged = { cards: 0, buckets: 0 };
+ try {
+ const cards = await fetch('api/cards').then(r => r.json());
+ const testCards = cards.filter(c => /^__.*__/.test(c.title || ''));
+ for (const c of testCards) {
+ await fetch(`api/cards/${c.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': token() } });
+ purged.cards++;
+ }
+ } catch {}
+ try {
+ const buckets = await fetch('api/buckets').then(r => r.json());
+ const testBuckets = buckets.filter(b => /^__.*__/.test(b.name || ''));
+ for (const b of testBuckets) {
+ await fetch(`api/buckets/${b.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': token() } });
+ purged.buckets++;
+ }
+ } catch {}
+ // Also clear any leftover editor drafts so the restore prompt doesn't fire
+ try {
+ for (const k of Object.keys(localStorage)) {
+ if (k.startsWith('hyprcrd-editor-draft')) localStorage.removeItem(k);
+ }
+ } catch {}
+ return purged;
+}
+
async function restoreBackup() {
if (!backupBlob) return;
const form = new FormData();
@@ -398,10 +429,18 @@ async function run() {
const output = panel.querySelector('#test-output');
const summary = panel.querySelector('#test-summary');
- // 1. Create backup
- output.innerHTML = 'creating backup...
';
+ // 1. Purge leftover test artifacts from any previous failed run
+ output.innerHTML = 'cleaning up previous test artifacts...
';
summary.textContent = '';
await sleep(50);
+ const purged = await purgeTestLeftovers();
+ if (purged.cards || purged.buckets) {
+ output.innerHTML += `purged ${purged.cards} leftover card${purged.cards !== 1 ? 's' : ''}, ${purged.buckets} bucket${purged.buckets !== 1 ? 's' : ''}
`;
+ }
+
+ // 2. Create backup (clean baseline)
+ output.innerHTML += 'creating backup...
';
+ await sleep(50);
try {
await createBackup();
} catch (e) {
diff --git a/public/plugins/tests/undo.test.js b/public/plugins/tests/undo.test.js
index e833acf..3fba79e 100644
--- a/public/plugins/tests/undo.test.js
+++ b/public/plugins/tests/undo.test.js
@@ -108,15 +108,22 @@ describe('Undo: Editor Save', () => {
bodyEl.value = 'after edit';
bodyEl.dispatchEvent(new Event('input', { bubbles: true }));
click('#modal-save-close');
- await sleep(400);
- let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
+ let card;
+ for (let i = 0; i < 40; i++) {
+ await sleep(100);
+ card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
+ if ((card?.body || '').includes('after edit')) break;
+ }
expect(card.body).toContain('after edit');
ctrlZ();
- await sleep(400);
- card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
+ for (let i = 0; i < 40; i++) {
+ await sleep(100);
+ card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
+ if ((card?.body || '').includes('before edit')) break;
+ }
expect(card.body).toContain('before edit');
});
diff --git a/src/CalDavSync.php b/src/CalDavSync.php
index 09f21ff..dd60ac7 100644
--- a/src/CalDavSync.php
+++ b/src/CalDavSync.php
@@ -381,11 +381,16 @@ class CalDavSync
$remoteMap = []; // uid -> event data (gets modified during reconciliation)
$remoteAll = []; // uid -> event data (unmodified copy for tombstone lookups)
$remoteEvents = [];
+ $fetchFailed = false;
foreach ($urls['all'] as $calUrl) {
$calSlug = basename(rtrim($calUrl, '/'));
$cal = $this->makeClient($calUrl);
$res = $cal->request('REPORT', $calUrl, '', ['Depth: 1']);
$this->addLog('sync', null, null, 'fetch', $res['status'] == 207 ? 'ok' : 'error', "REPORT {$calSlug} HTTP {$res['status']}");
+ if ($res['status'] != 207) {
+ $fetchFailed = true;
+ continue;
+ }
$events = $cal->listEvents();
$this->addLog('sync', null, null, 'fetch', 'ok', count($events) . " events from {$calSlug}");
$parseFail = 0;
@@ -403,6 +408,14 @@ class CalDavSync
if ($parseFail) $this->addLog('sync', null, null, 'fetch', 'error', "{$parseFail} events failed to parse from {$calSlug}");
}
+ // Abort on fetch failure — an incomplete remote list would make every
+ // local card look remotely-deleted and wipe them all. Better to sync
+ // nothing than to destroy local data on a flaky connection.
+ if ($fetchFailed) {
+ $this->addLog('sync', null, null, 'abort', 'error', 'remote fetch failed — skipping reconciliation to protect local data');
+ return $this->stats;
+ }
+
$this->addLog('sync', null, null, 'fetch', 'ok', "total remote: " . count($remoteMap) . " events");
// 3. Fetch local appointment cards — only #termin tagged or already synced