From 3cca14a3c674132b6d0030b9b5ea5f960e0d6e6e Mon Sep 17 00:00:00 2001 From: th Date: Tue, 14 Apr 2026 15:01:07 +0200 Subject: [PATCH] fixes: collab move, caldav abort, draft recovery, autobackup, test stabilization - Collab: moving a shared card to a local bucket no longer deletes it (clear relay_uid before tombstone echo returns) - CalDAV: abort sync on remote fetch failure instead of treating all local cards as remotely-deleted - Editor: autobackup drafts to localStorage with restore prompt on re-open (skipped during tests) - Cards POST: persist scratchpad and due_date (was dropped on create) - Disable spellcheck/autocorrect on editor inputs - Test runner: purge leftover __prefix__ cards/buckets + editor drafts before every run (cleans up from failed previous runs) - Stabilize mark-done, group-delete, undo-body tests with poll loops Co-Authored-By: Claude Opus 4.6 (1M context) --- 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