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) <noreply@anthropic.com>
This commit is contained in:
1 parent
7e2b130434
commit
3cca14a3c6
8 files changed
+178
-23
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
define('APP_NAME', 'hyprcrd');
|
||||||
define('APP_VERSION', '1.4.1');
|
define('APP_VERSION', '1.4.2');
|
||||||
|
|
||||||
// Database path — writable directory, outside webroot
|
// Database path — writable directory, outside webroot
|
||||||
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
||||||
|
|||||||
+7
-5
@@ -250,12 +250,14 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
if ($title === '') { http_response_code(400); echo json_encode(['error' => 'title is required']); exit; }
|
if ($title === '') { http_response_code(400); echo json_encode(['error' => 'title is required']); exit; }
|
||||||
$pos = $input['position'] ?? 0;
|
$pos = $input['position'] ?? 0;
|
||||||
$body = $input['body'] ?? '';
|
$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),
|
'title' => $title, 'body' => $body, 'color' => validColor($input['color'] ?? null),
|
||||||
'bucket_id' => validInt($input['bucket_id'] ?? null, null),
|
'bucket_id' => validInt($input['bucket_id'] ?? null, null),
|
||||||
'parent_id' => validInt($input['parent_id'] ?? null, null),
|
'parent_id' => validInt($input['parent_id'] ?? null, null),
|
||||||
'position' => validInt($pos),
|
'position' => validInt($pos),
|
||||||
'tags' => extractTags($body),
|
'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());
|
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;
|
exit;
|
||||||
@@ -1276,7 +1278,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<div id="quick-capture" class="hidden px-6 py-2 border-t border-t-border bg-t-bg">
|
<div id="quick-capture" class="hidden px-6 py-2 border-t border-t-border bg-t-bg">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<i class="ph ph-lightning text-t-accent text-sm"></i>
|
<i class="ph ph-lightning text-t-accent text-sm"></i>
|
||||||
<input id="quick-capture-input" type="text" placeholder="quick capture — type and press Enter" autocomplete="off" class="flex-1 bg-transparent border-none text-t-bright text-sm focus:outline-none placeholder:text-t-dim font-mono">
|
<input id="quick-capture-input" type="text" placeholder="quick capture — type and press Enter" autocomplete="off" spellcheck="false" autocorrect="off" autocapitalize="off" class="flex-1 bg-transparent border-none text-t-bright text-sm focus:outline-none placeholder:text-t-dim font-mono">
|
||||||
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 py-0.5 font-pixel">esc</kbd>
|
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 py-0.5 font-pixel">esc</kbd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2195,7 +2197,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3 px-4 py-2.5 border-b border-t-border shrink-0">
|
<div class="flex items-center gap-3 px-4 py-2.5 border-b border-t-border shrink-0">
|
||||||
<input type="text" id="inp-title" placeholder="> title..." class="flex-1 bg-transparent border-none text-t-bright text-base font-bold focus:outline-none placeholder:text-t-dim font-pixel">
|
<input type="text" id="inp-title" placeholder="> title..." spellcheck="false" autocorrect="off" autocapitalize="off" class="flex-1 bg-transparent border-none text-t-bright text-base font-bold focus:outline-none placeholder:text-t-dim font-pixel">
|
||||||
<button id="inp-due-date" class="flex items-center gap-1 px-2 py-1 text-[10px] font-pixel border border-t-border text-t-dim hover:border-t-accent hover:text-t-accent transition-colors rounded-sm shrink-0" data-tip="set due date">
|
<button id="inp-due-date" class="flex items-center gap-1 px-2 py-1 text-[10px] font-pixel border border-t-border text-t-dim hover:border-t-accent hover:text-t-accent transition-colors rounded-sm shrink-0" data-tip="set due date">
|
||||||
<i class="ph ph-clock-countdown text-xs"></i><span id="inp-due-label">No due</span>
|
<i class="ph ph-clock-countdown text-xs"></i><span id="inp-due-label">No due</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -2260,7 +2262,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="relative flex-1 min-h-0">
|
<div class="relative flex-1 min-h-0">
|
||||||
<div id="editor-line-hl" class="absolute pointer-events-none left-0 right-0" style="background:var(--t-border);opacity:0.18;display:none;z-index:1;"></div>
|
<div id="editor-line-hl" class="absolute pointer-events-none left-0 right-0" style="background:var(--t-border);opacity:0.18;display:none;z-index:1;"></div>
|
||||||
<textarea id="editor-body" placeholder="> write markdown..." class="absolute inset-0 w-full h-full text-t-text text-sm font-mono placeholder:text-t-dim border-none overflow-y-auto" style="padding:1rem; line-height:1.625; resize:none; z-index:2; background:transparent;"></textarea>
|
<textarea id="editor-body" placeholder="> write markdown..." spellcheck="false" autocorrect="off" autocapitalize="off" class="absolute inset-0 w-full h-full text-t-text text-sm font-mono placeholder:text-t-dim border-none overflow-y-auto" style="padding:1rem; line-height:1.625; resize:none; z-index:2; background:transparent;"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="editor-divider" class="hidden shrink-0" style="width:11px;cursor:col-resize;position:relative;z-index:3;margin:0 -5px;"><div style="position:absolute;left:5px;top:0;bottom:0;width:1px;background:var(--t-border);"></div><div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:4px;height:48px;border-radius:2px;background:var(--t-muted);opacity:0.08;transition:opacity 0.2s;"></div></div>
|
<div id="editor-divider" class="hidden shrink-0" style="width:11px;cursor:col-resize;position:relative;z-index:3;margin:0 -5px;"><div style="position:absolute;left:5px;top:0;bottom:0;width:1px;background:var(--t-border);"></div><div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:4px;height:48px;border-radius:2px;background:var(--t-muted);opacity:0.08;transition:opacity 0.2s;"></div></div>
|
||||||
@@ -2271,7 +2273,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<span class="font-pixel text-[9px] text-t-dim flex-1">scratchpad</span>
|
<span class="font-pixel text-[9px] text-t-dim flex-1">scratchpad</span>
|
||||||
<button id="scratchpad-clear" class="font-pixel text-[8px] text-t-muted hover:text-t-danger transition-colors">clear</button>
|
<button id="scratchpad-clear" class="font-pixel text-[8px] text-t-muted hover:text-t-danger transition-colors">clear</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea id="scratchpad-body" placeholder="private notes..." class="flex-1 text-t-muted font-mono placeholder:text-t-dim border-none overflow-y-auto bg-transparent" style="padding:1rem;font-size:11px;line-height:1.625;resize:none;outline:none;"></textarea>
|
<textarea id="scratchpad-body" placeholder="private notes..." spellcheck="false" autocorrect="off" autocapitalize="off" class="flex-1 text-t-muted font-mono placeholder:text-t-dim border-none overflow-y-auto bg-transparent" style="padding:1rem;font-size:11px;line-height:1.625;resize:none;outline:none;"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const inpTitle = $('inp-title');
|
|||||||
const inpColor = $('inp-color');
|
const inpColor = $('inp-color');
|
||||||
const inpBucket = $('inp-bucket');
|
const inpBucket = $('inp-bucket');
|
||||||
const presets = $('color-presets');
|
const presets = $('color-presets');
|
||||||
|
const scratchpadPanel = $('editor-scratchpad');
|
||||||
|
const scratchpadBody = $('scratchpad-body');
|
||||||
const linkAc = $('link-ac');
|
const linkAc = $('link-ac');
|
||||||
const tagAc = $('tag-ac');
|
const tagAc = $('tag-ac');
|
||||||
|
|
||||||
@@ -176,6 +178,45 @@ function selectColor(hex) {
|
|||||||
|
|
||||||
// ── Open / close ──
|
// ── 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) {
|
export function openModal(card = null) {
|
||||||
state.editingId = card ? card.id : null;
|
state.editingId = card ? card.id : null;
|
||||||
$('modal-title').textContent = card ? 'edit_card' : 'new_card';
|
$('modal-title').textContent = card ? 'edit_card' : 'new_card';
|
||||||
@@ -195,15 +236,44 @@ export function openModal(card = null) {
|
|||||||
if (linesVisible) updateGutter();
|
if (linesVisible) updateGutter();
|
||||||
currentLine = -1;
|
currentLine = -1;
|
||||||
updateActiveLine();
|
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();
|
inpTitle.focus();
|
||||||
// Plugins (collab-buckets) hook into this to heartbeat an edit lock
|
// Plugins (collab-buckets) hook into this to heartbeat an edit lock
|
||||||
window.dispatchEvent(new CustomEvent('hyprcrd:editor-open', { detail: { card } }));
|
window.dispatchEvent(new CustomEvent('hyprcrd:editor-open', { detail: { card } }));
|
||||||
}
|
}
|
||||||
|
|
||||||
inpTitle.addEventListener('input', updatePreview);
|
inpTitle.addEventListener('input', () => { updatePreview(); saveDraftDebounced(); });
|
||||||
inpTitle.addEventListener('keydown', e => {
|
inpTitle.addEventListener('keydown', e => {
|
||||||
if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); body.focus(); }
|
if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); body.focus(); }
|
||||||
});
|
});
|
||||||
|
body.addEventListener('input', saveDraftDebounced);
|
||||||
|
scratchpadBody?.addEventListener('input', saveDraftDebounced);
|
||||||
|
|
||||||
// ── Line numbers ──
|
// ── 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 };
|
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;
|
if (!data.title.trim()) return;
|
||||||
const returnToGallery = state.returnToGalleryAfterEdit;
|
const returnToGallery = state.returnToGalleryAfterEdit;
|
||||||
|
const wasEditingId = state.editingId;
|
||||||
if (state.editingId) {
|
if (state.editingId) {
|
||||||
const prev = snapshotCard(state.cards.find(c => c.id == 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 });
|
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 });
|
await api(`api/cards/${state.editingId}`, { method: 'PATCH', body: data });
|
||||||
|
clearDraft(state.editingId);
|
||||||
} else {
|
} else {
|
||||||
if (state.activeGroup) {
|
if (state.activeGroup) {
|
||||||
const parent = state.cards.find(c => c.id == 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._lastSavedId = created.id;
|
||||||
state.recentlyCreatedIds?.add(String(created.id));
|
state.recentlyCreatedIds?.add(String(created.id));
|
||||||
}
|
}
|
||||||
|
clearDraft(null); // 'new' draft
|
||||||
}
|
}
|
||||||
await window._reloadAll();
|
await window._reloadAll();
|
||||||
// Auto-sync if card has #termin or #geburtstag
|
// Auto-sync if card has #termin or #geburtstag
|
||||||
@@ -533,9 +606,6 @@ function applyEditorSplit() {
|
|||||||
preview.style.flex = `${100 - editorSplit} 1 0`;
|
preview.style.flex = `${100 - editorSplit} 1 0`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scratchpadPanel = $('editor-scratchpad');
|
|
||||||
const scratchpadBody = $('scratchpad-body');
|
|
||||||
|
|
||||||
function showSidePanel(which) {
|
function showSidePanel(which) {
|
||||||
const showPreview = which === 'preview';
|
const showPreview = which === 'preview';
|
||||||
const showScratch = which === 'scratchpad';
|
const showScratch = which === 'scratchpad';
|
||||||
|
|||||||
@@ -411,9 +411,16 @@ function collabSyncCards(PDO $db): array {
|
|||||||
$stats['moved']++;
|
$stats['moved']++;
|
||||||
|
|
||||||
if ($mv['card_missing'] || empty($mv['new_bucket_uid'])) {
|
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')
|
$db->prepare('DELETE FROM collab_card_state WHERE card_id = :id')
|
||||||
->execute(['id' => $mv['card_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 {
|
} else {
|
||||||
// Card moved to a different collab bucket. Rewrite state so the
|
// Card moved to a different collab bucket. Rewrite state so the
|
||||||
// push phase below picks it up under the new bucket_uid and also
|
// push phase below picks it up under the new bucket_uid and also
|
||||||
|
|||||||
@@ -157,14 +157,26 @@ describe('Card: Context Menu Actions', () => {
|
|||||||
expect(doneBtn.textContent.toLowerCase()).toContain('mark done');
|
expect(doneBtn.textContent.toLowerCase()).toContain('mark done');
|
||||||
|
|
||||||
click(doneBtn);
|
click(doneBtn);
|
||||||
await sleep(300);
|
// Poll until #done is persisted to the DB
|
||||||
let updated = await fetch(`api/cards?ids=${res.id}`).then(r => r.json());
|
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].body || '').toLowerCase()).toContain('#done');
|
||||||
expect(updated[0].tags || '').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"
|
// Re-open menu — the entry should now flip to "Unmark done"
|
||||||
rightClick(document.querySelector(`[data-id="${res.id}"]`));
|
rightClick(document.querySelector(`[data-id="${res.id}"]`));
|
||||||
await sleep(150);
|
await sleep(200);
|
||||||
const doneBtn2 = $$('#ctx-items [data-action="due-done"]')[0];
|
const doneBtn2 = $$('#ctx-items [data-action="due-done"]')[0];
|
||||||
expect(doneBtn2.textContent.toLowerCase()).toContain('unmark');
|
expect(doneBtn2.textContent.toLowerCase()).toContain('unmark');
|
||||||
|
|
||||||
@@ -331,11 +343,16 @@ describe('Card: Group Delete', () => {
|
|||||||
// Confirm the delete
|
// Confirm the delete
|
||||||
const okBtn = document.getElementById('dialog-ok');
|
const okBtn = document.getElementById('dialog-ok');
|
||||||
if (okBtn) click(okBtn);
|
if (okBtn) click(okBtn);
|
||||||
await sleep(500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// All three cards should be gone
|
// Poll until all three cards are gone (group delete = N serial DELETEs)
|
||||||
const remaining = await fetch('api/cards').then(r => r.json());
|
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 === parent.id)).toBe(false);
|
||||||
expect(remaining.some(c => c.id === child1.id)).toBe(false);
|
expect(remaining.some(c => c.id === child1.id)).toBe(false);
|
||||||
expect(remaining.some(c => c.id === child2.id)).toBe(false);
|
expect(remaining.some(c => c.id === child2.id)).toBe(false);
|
||||||
|
|||||||
@@ -201,6 +201,37 @@ async function createBackup() {
|
|||||||
backupBlob = await res.blob();
|
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() {
|
async function restoreBackup() {
|
||||||
if (!backupBlob) return;
|
if (!backupBlob) return;
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
@@ -398,10 +429,18 @@ async function run() {
|
|||||||
const output = panel.querySelector('#test-output');
|
const output = panel.querySelector('#test-output');
|
||||||
const summary = panel.querySelector('#test-summary');
|
const summary = panel.querySelector('#test-summary');
|
||||||
|
|
||||||
// 1. Create backup
|
// 1. Purge leftover test artifacts from any previous failed run
|
||||||
output.innerHTML = '<div style="color:#64748b;">creating backup...</div>';
|
output.innerHTML = '<div style="color:#64748b;">cleaning up previous test artifacts...</div>';
|
||||||
summary.textContent = '';
|
summary.textContent = '';
|
||||||
await sleep(50);
|
await sleep(50);
|
||||||
|
const purged = await purgeTestLeftovers();
|
||||||
|
if (purged.cards || purged.buckets) {
|
||||||
|
output.innerHTML += `<div style="color:#fbbf24;">purged ${purged.cards} leftover card${purged.cards !== 1 ? 's' : ''}, ${purged.buckets} bucket${purged.buckets !== 1 ? 's' : ''}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create backup (clean baseline)
|
||||||
|
output.innerHTML += '<div style="color:#64748b;">creating backup...</div>';
|
||||||
|
await sleep(50);
|
||||||
try {
|
try {
|
||||||
await createBackup();
|
await createBackup();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -108,15 +108,22 @@ describe('Undo: Editor Save', () => {
|
|||||||
bodyEl.value = 'after edit';
|
bodyEl.value = 'after edit';
|
||||||
bodyEl.dispatchEvent(new Event('input', { bubbles: true }));
|
bodyEl.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
click('#modal-save-close');
|
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');
|
expect(card.body).toContain('after edit');
|
||||||
|
|
||||||
ctrlZ();
|
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');
|
expect(card.body).toContain('before edit');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -381,11 +381,16 @@ class CalDavSync
|
|||||||
$remoteMap = []; // uid -> event data (gets modified during reconciliation)
|
$remoteMap = []; // uid -> event data (gets modified during reconciliation)
|
||||||
$remoteAll = []; // uid -> event data (unmodified copy for tombstone lookups)
|
$remoteAll = []; // uid -> event data (unmodified copy for tombstone lookups)
|
||||||
$remoteEvents = [];
|
$remoteEvents = [];
|
||||||
|
$fetchFailed = false;
|
||||||
foreach ($urls['all'] as $calUrl) {
|
foreach ($urls['all'] as $calUrl) {
|
||||||
$calSlug = basename(rtrim($calUrl, '/'));
|
$calSlug = basename(rtrim($calUrl, '/'));
|
||||||
$cal = $this->makeClient($calUrl);
|
$cal = $this->makeClient($calUrl);
|
||||||
$res = $cal->request('REPORT', $calUrl, '<?xml version="1.0"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/><c:calendar-data/></d:prop><c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"/></c:comp-filter></c:filter></c:calendar-query>', ['Depth: 1']);
|
$res = $cal->request('REPORT', $calUrl, '<?xml version="1.0"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/><c:calendar-data/></d:prop><c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"/></c:comp-filter></c:filter></c:calendar-query>', ['Depth: 1']);
|
||||||
$this->addLog('sync', null, null, 'fetch', $res['status'] == 207 ? 'ok' : 'error', "REPORT {$calSlug} HTTP {$res['status']}");
|
$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();
|
$events = $cal->listEvents();
|
||||||
$this->addLog('sync', null, null, 'fetch', 'ok', count($events) . " events from {$calSlug}");
|
$this->addLog('sync', null, null, 'fetch', 'ok', count($events) . " events from {$calSlug}");
|
||||||
$parseFail = 0;
|
$parseFail = 0;
|
||||||
@@ -403,6 +408,14 @@ class CalDavSync
|
|||||||
if ($parseFail) $this->addLog('sync', null, null, 'fetch', 'error', "{$parseFail} events failed to parse from {$calSlug}");
|
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");
|
$this->addLog('sync', null, null, 'fetch', 'ok', "total remote: " . count($remoteMap) . " events");
|
||||||
|
|
||||||
// 3. Fetch local appointment cards — only #termin tagged or already synced
|
// 3. Fetch local appointment cards — only #termin tagged or already synced
|
||||||
|
|||||||
Reference in new issue
Block a user