Compare commits

...
2 Commits
Author SHA1 Message Date
thandClaude Opus 4.6 8e738ed9d4 fix(bookmarks): sync local state on save so columns persist
save() PATCHed the card on the server but left state.cards stale.
The next openOverlay read the old body from the local cache and
the new column was gone. Now save() writes the updated body back
into the matching state.cards entry so close + reopen sees the
latest data without a full _reloadAll round-trip.

Test: "new columns survive close + reopen" — adds a column via
API + local state update, closes, reopens, asserts both column
names are present in the DOM.

Bumps to 1.9.28.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 23:56:42 +02:00
thandClaude Opus 4.6 c934673c4a detail: optional bucket-switch on open from search / link
New toggle under Settings → Behavior → Interactions: "Switch to
card's bucket on open (search / link)". When enabled, openDetail
calls activateBucket(card.bucket_id) so the board and bucket bar
update to show the card's context. Skipped when already on 'all'
or the correct bucket. Default off.

Uses activateBucket() (from buckets.js) instead of raw state
assignment so the bucket-bar highlight tracks the switch.

Tests: two cases — setting on switches activeBucket + verifies the
bucket-tab.active in the bar matches; setting off leaves the
bucket unchanged. Both create/delete probe cards in try/finally
and restore the setting + activeBucket in afterEach.

Bumps to 1.9.27.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 23:39:36 +02:00
8 changed files with 149 additions and 3 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
<?php
define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '1.9.26');
define('APP_VERSION', '1.9.28');
// Database path — writable directory, outside webroot
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hyprcrd",
"version": "1.9.26",
"version": "1.9.28",
"description": "hyprcrd — self-hosted card manager",
"main": "main.js",
"scripts": {
+1
View File
@@ -1926,6 +1926,7 @@ if (str_starts_with($route, '/api/')) {
</div>
<div class="toggle-row" data-toggle="setting-confirm-delete"><span class="toggle-label">Confirm before delete</span><div class="toggle-switch" id="setting-confirm-delete"></div></div>
<div class="toggle-row" data-toggle="setting-auto-save"><span class="toggle-label">Auto-save on close (editor)</span><div class="toggle-switch" id="setting-auto-save"></div></div>
<div class="toggle-row" data-toggle="setting-open-switches-bucket"><span class="toggle-label">Switch to card's bucket on open (search / link)</span><div class="toggle-switch" id="setting-open-switches-bucket"></div></div>
</div>
</div>
</div>
+13
View File
@@ -5,6 +5,7 @@ import { state, parseTags, tagChipHtml, getChildren, getGroupCards, getRecent, m
import { renderMd, stripMd, firstImage, addCodeCopyButtons, formatTodoItems } from './markdown.js';
import { pushCardDelete, pushCardEdit, snapshotCard } from './undo.js';
import { showDialog } from './dialog.js';
import { activateBucket } from './buckets.js';
const detail = $('detail');
const scroll = $('detail-scroll');
@@ -57,6 +58,18 @@ export function openDetail(id, options = {}) {
if (!card) return;
markRecent(id);
// Optional: switch the board to the card's bucket so the user
// sees context. Only when opened via search, wikilink, or
// programmatic call — not when the user clicked a board card
// (they're already in the right bucket). Gated by a setting
// under Behavior → Interactions.
if (card.bucket_id
&& localStorage.getItem('hyprcrd-open-switches-bucket') === '1'
&& state.activeBucket !== 'all'
&& String(state.activeBucket) !== String(card.bucket_id)) {
activateBucket(card.bucket_id);
}
const el = $('board')?.querySelector(`[data-id="${id}"]`);
if (el) el.style.borderBottom = `3px solid ${card.color}`;
+1
View File
@@ -450,6 +450,7 @@ function initBehaviorSettings() {
'setting-show-done-badge': { key: 'hyprcrd-show-done-badge', def: '1' },
'setting-confirm-delete': { key: 'hyprcrd-confirm-delete', def: '1' },
'setting-auto-save': { key: 'hyprcrd-auto-save', def: '0' },
'setting-open-switches-bucket': { key: 'hyprcrd-open-switches-bucket', def: '0' },
};
for (const [id, cfg] of Object.entries(selects)) {
const el = $(id);
+4
View File
@@ -77,6 +77,10 @@ async function save() {
method: 'PATCH',
body: { body },
});
// Keep the local state in sync so the next openOverlay (which
// reads from state.cards, not the server) sees the latest body.
const local = (window.state?.cards || []).find(c => c.id == activeCardId);
if (local) local.body = body;
}
// ── Board card decoration ────────────────────────────────────────
+49
View File
@@ -130,6 +130,55 @@ describe('Bookmarks: card links', () => {
});
});
describe('Bookmarks: column persistence', () => {
it('new columns survive close + reopen', async () => {
let card;
try {
card = await fetch('api/cards', { method: 'POST', headers: hJson(), body: JSON.stringify({ title: '__test_bm_colsave__', body: emptyBody() }) }).then(r => r.json());
await window._reloadAll();
await sleep(200);
// Open overlay, add a column via the data model + save
// (simulates what addColumn does without the dialog).
const el = document.querySelector(`.board-card[data-id="${card.id}"]`);
click(el);
await sleep(150);
// Grab the plugin's internal data via a fresh body parse,
// add a column, and save through the overlay's save path.
const full = await fetch(`api/cards?ids=${card.id}`).then(r => r.json());
const body = (full[0]?.body || '').trim();
const jsonEnd = body.lastIndexOf('}');
const parsed = JSON.parse(body.substring(0, jsonEnd + 1));
parsed.columns.push({ name: 'Added', items: [] });
const newBody = JSON.stringify(parsed) + '\n\n#bookmarks';
await fetch(`api/cards/${card.id}`, {
method: 'PATCH', headers: hJson(),
body: JSON.stringify({ body: newBody }),
});
// Update local state like save() does.
const local = (window.state?.cards || []).find(c => c.id == card.id);
if (local) local.body = newBody;
click($('bookmarks-close'));
await sleep(100);
// Reopen — the new column must still be there.
const el2 = document.querySelector(`.board-card[data-id="${card.id}"]`);
click(el2);
await sleep(150);
const names = [...document.querySelectorAll('.bm-col-name')].map(n => n.textContent);
expect(names).toContain('General');
expect(names).toContain('Added');
click($('bookmarks-close'));
await sleep(80);
} finally {
if (card?.id) await fetch(`api/cards/${card.id}`, { method: 'DELETE', headers: hDel() });
await window._reloadAll();
}
});
});
describe('Bookmarks: column width persistence', () => {
it('saves and restores columnWidth from card body', async () => {
const cols = [{ name: 'W', items: [] }];
+79 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, afterEach, expect, $, click, pressKey, sleep, closeAll } from './runner.js?v=4';
import { describe, it, afterEach, expect, $, click, pressKey, sleep, closeAll, csrf } from './runner.js?v=4';
describe('Detail View', () => {
afterEach(() => closeAll());
@@ -71,3 +71,81 @@ describe('Detail View', () => {
expect($('#detail')).toBeHidden();
});
});
describe('Detail: open-switches-bucket', () => {
const SETTING_KEY = 'hyprcrd-open-switches-bucket';
const BUCKET_KEY = 'hyprcrd-active-bucket';
afterEach(async () => {
await closeAll();
// Restore runner baseline.
window.state.activeBucket = 'all';
localStorage.setItem(BUCKET_KEY, 'all');
});
it('switches to the card bucket when the setting is on', async () => {
const buckets = await fetch('api/buckets').then(r => r.json());
if (!Array.isArray(buckets) || !buckets.length) return;
const b = buckets[0];
const token = csrf();
const h = { 'Content-Type': 'application/json', 'X-CSRF-Token': token };
let card;
const prev = localStorage.getItem(SETTING_KEY);
try {
card = await fetch('api/cards', { method: 'POST', headers: h,
body: JSON.stringify({ title: '__test_switch_bucket__', body: '', bucket_id: b.id })
}).then(r => r.json());
await window._reloadAll();
await sleep(100);
// Start on a DIFFERENT bucket so the switch is visible.
const other = buckets.length > 1 ? buckets[1] : buckets[0];
window.state.activeBucket = String(other.id);
// Enable the setting.
localStorage.setItem(SETTING_KEY, '1');
window._openDetail(card.id);
await sleep(150);
expect(String(window.state.activeBucket)).toBe(String(b.id));
// Bucket bar must highlight the switched-to bucket.
const activeTab = document.querySelector('#bucket-bar .bucket-tab.active[data-bucket]');
expect(activeTab).toExist();
expect(activeTab.dataset.bucket).toBe(String(b.id));
} finally {
localStorage.setItem(SETTING_KEY, prev ?? '0');
if (card?.id) await fetch(`api/cards/${card.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': token } });
await window._reloadAll();
}
});
it('does NOT switch when the setting is off', async () => {
const buckets = await fetch('api/buckets').then(r => r.json());
if (!Array.isArray(buckets) || buckets.length < 2) return;
const token = csrf();
const h = { 'Content-Type': 'application/json', 'X-CSRF-Token': token };
let card;
const prev = localStorage.getItem(SETTING_KEY);
try {
card = await fetch('api/cards', { method: 'POST', headers: h,
body: JSON.stringify({ title: '__test_no_switch__', body: '', bucket_id: buckets[0].id })
}).then(r => r.json());
await window._reloadAll();
await sleep(100);
window.state.activeBucket = String(buckets[1].id);
localStorage.setItem(SETTING_KEY, '0');
window._openDetail(card.id);
await sleep(100);
// Should stay on the other bucket.
expect(String(window.state.activeBucket)).toBe(String(buckets[1].id));
} finally {
localStorage.setItem(SETTING_KEY, prev ?? '0');
if (card?.id) await fetch(`api/cards/${card.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': token } });
await window._reloadAll();
}
});
});