@@ -2148,6 +2161,8 @@ if (str_starts_with($route, '/api/')) {
+
+
= PluginManager::instance()->slot('md-toolbar') ?>
diff --git a/public/js/app.js b/public/js/app.js
index 703824f..4f5680e 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -9,7 +9,7 @@ import './modules/theme.js';
import { loadBuckets, renderTagBar, activateBucket } from './modules/buckets.js';
import { renderBoard, updateSortUI, getSelectedIds, clearSelection } from './modules/board.js';
import { openDetail, closeDetail, showDetailCard, ungroupCard } from './modules/detail.js';
-import { openModal, closeModal, saveCard, saveOnly } from './modules/editor.js';
+import { openModal, closeModal, saveCard, saveOnly, openFindBar } from './modules/editor.js';
import { openSearch, closeSearch } from './modules/search.js';
import { showContextMenu } from './modules/context-menu.js';
import { undo, canUndo } from './modules/undo.js';
@@ -39,6 +39,7 @@ window._ungroupCard = ungroupCard;
window._getSelectedIds = getSelectedIds;
window._clearSelection = clearSelection;
window._refreshTasks = refreshTasksView;
+window.openFindBar = openFindBar;
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).
@@ -240,7 +241,7 @@ registerShortcut({ key: '#', guard: () => false, section: 'editor', keys: '
registerShortcut({ key: '?', guard: () => false, section: 'editor', keys: '?snippet', label: 'snippet expand' });
registerShortcut({ key: 'h', guard: () => false, section: 'editor', keys: '⌃ H', label: 'find + replace' });
registerShortcut({ key: 'f', guard: () => false, section: 'editor', keys: '⌃ F', label: 'find' });
-registerShortcut({ key: 'H', ctrl: true, shift: true, guard: () => !isDialogOpen() && !guards.isOpen('modal'), section: 'global', keys: '⌃ ⇧ H', label: 'find + replace across cards', handler: () => openBoardFindReplace() });
+registerShortcut({ key: 'H', ctrl: true, shift: true, guard: () => !isDialogOpen() && !guards.isOpen('modal'), section: 'global', keys: '⌃ ⇧ H', label: 'find + replace across cards', handler: () => openSearch({ replace: true }) });
// — Detail view —
registerShortcut({ match: (e) => (e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !e.ctrlKey && guards.isOpen('detail') && !!state.viewingCard, priority: 10, section: 'board + detail', keys: '← → in preview', label: 'group prev / next', handler: (e) => {
@@ -509,148 +510,6 @@ function openTagQuickPick() {
setTimeout(() => input.focus(), 0);
}
-// ── Board-level find/replace (Ctrl+Shift+H) ──
-
-async function openBoardFindReplace() {
- const existing = document.getElementById('board-find-replace');
- if (existing) { existing.remove(); return; }
-
- // Fetch all cards with bodies (state.cards may have body=null from lazy load)
- const allCards = await api('api/cards') || [];
-
- const overlay = document.createElement('div');
- overlay.id = 'board-find-replace';
- overlay.style.cssText = 'position:fixed;inset:0;z-index:120;background:var(--t-overlay,rgba(0,0,0,0.55));display:flex;align-items:flex-start;justify-content:center;padding:60px 16px 16px;backdrop-filter:blur(var(--ui-blur-overlay,0));';
- const box = document.createElement('div');
- box.style.cssText = 'background:var(--t-surface);border:1px solid var(--t-border);border-radius:var(--ui-radius-modal);box-shadow:var(--ui-shadow-modal);width:100%;max-width:560px;max-height:80vh;display:flex;flex-direction:column;overflow:hidden;';
- box.innerHTML = `
-
-
-
- `;
- overlay.appendChild(box);
- document.body.appendChild(overlay);
-
- const findInp = box.querySelector('#bfr-find');
- const replInp = box.querySelector('#bfr-replace');
- const list = box.querySelector('#bfr-list');
- const statusEl = box.querySelector('#bfr-status');
- const applyBtn = box.querySelector('#bfr-apply');
-
- function escHtml(s) { return String(s).replace(/&/g, '&').replace(//g, '>'); }
-
- let matches = [];
-
- function search() {
- const needle = findInp.value;
- matches = [];
- if (!needle) {
- list.innerHTML = 'type to search across all card bodies
';
- statusEl.textContent = '';
- applyBtn.disabled = true;
- return;
- }
- const nLower = needle.toLowerCase();
- for (const c of allCards) {
- const body = c.body || '';
- const idx = body.toLowerCase().indexOf(nLower);
- if (idx === -1) continue;
- // Extract context around the first match
- const start = Math.max(0, idx - 30);
- const end = Math.min(body.length, idx + needle.length + 30);
- const before = body.substring(start, idx);
- const match = body.substring(idx, idx + needle.length);
- const after = body.substring(idx + needle.length, end);
- // Count all occurrences in this card
- let count = 0, pos = 0;
- while (true) {
- const i = body.toLowerCase().indexOf(nLower, pos);
- if (i === -1) break;
- count++;
- pos = i + 1;
- }
- matches.push({ card: c, context: { before, match, after }, count });
- }
- const totalOccurrences = matches.reduce((s, m) => s + m.count, 0);
- statusEl.textContent = matches.length
- ? `${totalOccurrences} occurrence${totalOccurrences !== 1 ? 's' : ''} in ${matches.length} card${matches.length !== 1 ? 's' : ''}`
- : 'no matches';
- applyBtn.disabled = !matches.length || !replInp.value.length;
-
- if (!matches.length) {
- list.innerHTML = 'no matches found
';
- return;
- }
- list.innerHTML = matches.map(m => `
-
-
-
-
${escHtml(m.card.title)} (${m.count}×)
-
…${escHtml(m.context.before)}${escHtml(m.context.match)}${escHtml(m.context.after)}…
-
-
- `).join('');
- }
-
- findInp.addEventListener('input', search);
- replInp.addEventListener('input', () => { applyBtn.disabled = !matches.length || !replInp.value.length; });
-
- applyBtn.addEventListener('click', async () => {
- const needle = findInp.value;
- const repl = replInp.value;
- if (!needle || !matches.length) return;
- const totalOccurrences = matches.reduce((s, m) => s + m.count, 0);
- const ok = await showDialog({
- title: 'replace_all',
- message: `Replace ${totalOccurrences} occurrence${totalOccurrences !== 1 ? 's' : ''} of "${needle}" with "${repl}" across ${matches.length} card${matches.length !== 1 ? 's' : ''}?`,
- danger: true,
- okLabel: `Replace ${totalOccurrences}`,
- });
- if (!ok) return;
- const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- const re = new RegExp(escaped, 'gi');
- let patchCount = 0;
- for (const m of matches) {
- const newBody = (m.card.body || '').replace(re, repl);
- if (newBody !== m.card.body) {
- await api(`api/cards/${m.card.id}`, { method: 'PATCH', body: { body: newBody } });
- patchCount++;
- }
- }
- statusEl.textContent = `done — ${patchCount} card${patchCount !== 1 ? 's' : ''} updated`;
- statusEl.style.color = 'var(--t-accent)';
- applyBtn.disabled = true;
- matches = [];
- list.innerHTML = '✓ replaced
';
- await window._reloadAll();
- });
-
- function close() {
- overlay.remove();
- document.removeEventListener('keydown', onKey, true);
- }
- function onKey(e) {
- if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); }
- }
- overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
- box.querySelector('#bfr-close').addEventListener('click', close);
- document.addEventListener('keydown', onKey, true);
- setTimeout(() => findInp.focus(), 0);
-}
-
// ── Boot ──
renderShortcutHelp();
diff --git a/public/js/modules/editor.js b/public/js/modules/editor.js
index 550a4c5..4653420 100644
--- a/public/js/modules/editor.js
+++ b/public/js/modules/editor.js
@@ -1001,7 +1001,7 @@ const replRow = $('replace-row');
let findMatches = [];
let findCurrent = -1;
-function openFindBar(withReplace = true) {
+export function openFindBar(withReplace = true) {
if (!findBar) return;
findBar.classList.remove('hidden');
replRow?.classList.toggle('hidden', !withReplace);
diff --git a/public/js/modules/search.js b/public/js/modules/search.js
index 77503f3..8f884bf 100644
--- a/public/js/modules/search.js
+++ b/public/js/modules/search.js
@@ -3,6 +3,7 @@
import { $, esc, escRe, api, fuzzyMatch, countSubstring, highlight } from './util.js';
import { state, parseTags, tagChipHtml, getChildren } from './state.js';
import { renderMd, stripMd } from './markdown.js';
+import { showDialog } from './dialog.js';
const overlay = $('search');
const input = $('search-input');
@@ -11,20 +12,59 @@ const footer = $('search-footer');
const previewEmpty = $('search-preview-empty');
const previewContent = $('search-preview-content');
const bucketFilter = $('search-bucket-filter');
-let active = -1, hits = [];
+const replaceToggle = $('search-replace-toggle');
+const replaceRow = $('search-replace-row');
+const replaceInput = $('search-replace-input');
+const replaceStatus = $('search-replace-status');
+const replaceAllBtn = $('search-replace-all');
+let active = -1, hits = [], replaceMode = false, freshCards = null;
-export function openSearch() {
+export function openSearch({ replace = false } = {}) {
window._closeTasks?.();
input.value = '';
results.innerHTML = '';
footer.classList.add('hidden');
active = -1;
overlay.classList.add('open');
+ setReplaceMode(replace);
input.focus();
clearPreview();
renderHome();
}
+async function setReplaceMode(on) {
+ replaceMode = on;
+ replaceRow?.classList.toggle('hidden', !on);
+ replaceToggle?.classList.toggle('active', on);
+ if (on) {
+ replaceInput.value = '';
+ freshCards = await api('api/cards') || [];
+ } else {
+ freshCards = null;
+ }
+ updateReplaceState();
+}
+
+function updateReplaceState() {
+ if (!replaceMode || !replaceAllBtn) return;
+ const q = input.value.trim();
+ const hasRepl = replaceInput?.value.length > 0;
+ const hasMatch = hits.length > 0 && q.length > 0;
+ replaceAllBtn.disabled = !(hasMatch && hasRepl);
+ if (q && hits.length) {
+ let total = 0;
+ const nLower = q.toLowerCase();
+ for (const c of hits) {
+ const body = (c.body || '').toLowerCase();
+ let pos = 0;
+ while (true) { const i = body.indexOf(nLower, pos); if (i === -1) break; total++; pos = i + 1; }
+ }
+ replaceStatus.textContent = total ? `${total} in ${hits.length} card${hits.length !== 1 ? 's' : ''}` : '';
+ } else {
+ replaceStatus.textContent = '';
+ }
+}
+
export function closeSearch() {
overlay.classList.remove('open');
if (document.activeElement && document.activeElement !== document.body) {
@@ -35,10 +75,11 @@ export function closeSearch() {
function clearPreview() { previewEmpty.classList.remove('hidden'); previewContent.classList.add('hidden'); }
function getPool() {
+ const cards = (replaceMode && freshCards) ? freshCards : state.cards;
const bv = bucketFilter.value;
- if (!bv) return state.cards;
- if (bv === 'none') return state.cards.filter(c => !c.bucket_id);
- return state.cards.filter(c => c.bucket_id == bv);
+ if (!bv) return cards;
+ if (bv === 'none') return cards.filter(c => !c.bucket_id);
+ return cards.filter(c => c.bucket_id == bv);
}
function updatePreview() {
@@ -90,7 +131,7 @@ function renderHome() {
recent.map((c, i) => hitHtml(c, i, null)).join('');
hits = recent; active = recent.length ? 0 : -1;
footer.classList.remove('hidden');
- bindHits(); updateActive(); updatePreview();
+ bindHits(); updateActive(); updatePreview(); updateReplaceState();
}
function search(query) {
@@ -115,10 +156,10 @@ function search(query) {
// Tag-only search (no words)
if (!words.length) {
hits = pool; active = pool.length ? 0 : -1;
- if (!pool.length) { results.innerHTML = `no cards with ${tagFilters.map(t => '#' + t).join(' + ')}
`; footer.classList.add('hidden'); clearPreview(); return; }
+ if (!pool.length) { results.innerHTML = `no cards with ${tagFilters.map(t => '#' + t).join(' + ')}
`; footer.classList.add('hidden'); clearPreview(); updateReplaceState(); return; }
results.innerHTML = `${pool.length} tagged
` +
pool.map((c, i) => hitHtml(c, i, null)).join('');
- footer.classList.remove('hidden'); bindHits(); updateActive(); updatePreview(); return;
+ footer.classList.remove('hidden'); bindHits(); updateActive(); updatePreview(); updateReplaceState(); return;
}
const scored = pool.map(c => {
@@ -146,10 +187,10 @@ function search(query) {
}).filter(Boolean).sort((a, b) => b.score - a.score);
hits = scored.map(s => s.card); active = scored.length ? 0 : -1;
- if (!scored.length) { results.innerHTML = `no results for "${esc(query)}"
`; footer.classList.add('hidden'); clearPreview(); return; }
+ if (!scored.length) { results.innerHTML = `no results for "${esc(query)}"
`; footer.classList.add('hidden'); clearPreview(); updateReplaceState(); return; }
results.innerHTML = `${scored.length} result${scored.length > 1 ? 's' : ''}
` +
scored.map((s, i) => hitHtml(s.card, i, words)).join('');
- footer.classList.remove('hidden'); bindHits(); updateActive(); updatePreview();
+ footer.classList.remove('hidden'); bindHits(); updateActive(); updatePreview(); updateReplaceState();
}
function hitHtml(card, idx, words) {
@@ -251,3 +292,44 @@ input.addEventListener('keydown', e => {
overlay.addEventListener('click', e => { if (e.target === overlay) closeSearch(); });
$('search-btn')?.addEventListener('click', openSearch);
$('search-close')?.addEventListener('click', closeSearch);
+replaceToggle?.addEventListener('click', () => setReplaceMode(!replaceMode));
+replaceInput?.addEventListener('input', updateReplaceState);
+
+replaceAllBtn?.addEventListener('click', async () => {
+ const needle = input.value.trim();
+ const repl = replaceInput.value;
+ if (!needle || !hits.length) return;
+ let totalOcc = 0;
+ const nLower = needle.toLowerCase();
+ for (const c of hits) {
+ let pos = 0;
+ const body = (c.body || '').toLowerCase();
+ while (true) { const i = body.indexOf(nLower, pos); if (i === -1) break; totalOcc++; pos = i + 1; }
+ }
+ const ok = await showDialog({
+ title: 'replace_all',
+ message: `Replace ${totalOcc} occurrence${totalOcc !== 1 ? 's' : ''} of "${needle}" with "${repl}" across ${hits.length} card${hits.length !== 1 ? 's' : ''}?`,
+ danger: true, okLabel: `Replace ${totalOcc}`,
+ });
+ if (!ok) return;
+ const freshCards = await api('api/cards') || [];
+ const freshMap = new Map(freshCards.map(c => [c.id, c]));
+ const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const re = new RegExp(escaped, 'gi');
+ let patched = 0;
+ for (const c of hits) {
+ const fresh = freshMap.get(c.id);
+ const body = fresh?.body || c.body || '';
+ const newBody = body.replace(re, repl);
+ if (newBody !== body) {
+ await api(`api/cards/${c.id}`, { method: 'PATCH', body: { body: newBody } });
+ patched++;
+ }
+ }
+ replaceStatus.textContent = `done — ${patched} card${patched !== 1 ? 's' : ''} updated`;
+ replaceStatus.style.color = 'var(--t-accent)';
+ replaceAllBtn.disabled = true;
+ await window._reloadAll();
+ search(input.value);
+ setTimeout(() => { replaceStatus.style.color = ''; }, 3000);
+});
diff --git a/public/plugins/tests/board-find-replace.test.js b/public/plugins/tests/board-find-replace.test.js
index a022c57..5d72e3d 100644
--- a/public/plugins/tests/board-find-replace.test.js
+++ b/public/plugins/tests/board-find-replace.test.js
@@ -13,59 +13,37 @@ async function getCard(id) {
return all.find(c => c.id == id);
}
-function openBoardFR() {
- // Ctrl+Shift+H opens the board find/replace overlay
+function openSearchReplace() {
document.dispatchEvent(new KeyboardEvent('keydown', {
key: 'H', ctrlKey: true, shiftKey: true, bubbles: true,
}));
}
-describe('Board: Find/Replace', () => {
+describe('Search: Find/Replace', () => {
afterEach(async () => {
- document.getElementById('board-find-replace')?.remove();
await closeAll();
});
- it('Ctrl+Shift+H opens the overlay', async () => {
- openBoardFR();
- // Wait for the overlay (it does an async api('api/cards') fetch)
- for (let i = 0; i < 40; i++) {
- if (document.getElementById('board-find-replace')) break;
- await sleep(50);
- }
- expect(document.getElementById('board-find-replace')).toExist();
- expect(document.getElementById('bfr-find')).toExist();
- expect(document.getElementById('bfr-replace')).toExist();
+ it('Ctrl+Shift+H opens search with replace row visible', async () => {
+ openSearchReplace();
+ await sleep(200);
+ const overlay = document.getElementById('search');
+ expect(overlay?.classList.contains('open')).toBe(true);
+ expect(document.getElementById('search-replace-row')).toBeVisible();
+ expect(document.getElementById('search-replace-input')).toExist();
+ expect(document.getElementById('search-replace-all')).toExist();
});
- it('typing in find shows matching cards', async () => {
- const c1 = await makeCard('__bfr_match_a__', 'the quick brown fox');
- const c2 = await makeCard('__bfr_match_b__', 'the quick red fox');
- const c3 = await makeCard('__bfr_nomatch__', 'no match here');
- await window._reloadAll?.();
+ it('replace toggle shows/hides replace row', async () => {
+ openSearchReplace();
await sleep(200);
-
- openBoardFR();
- for (let i = 0; i < 40; i++) {
- if (document.getElementById('bfr-find')) break;
- await sleep(50);
- }
-
- const findInp = document.getElementById('bfr-find');
- if (!findInp) return;
- findInp.value = 'quick';
- findInp.dispatchEvent(new Event('input', { bubbles: true }));
- await sleep(100);
-
- const status = document.getElementById('bfr-status');
- // Should find 'quick' in at least 2 cards (c1 + c2)
- expect(status.textContent).toContain('2');
- // The list should mention both matching card titles
- const list = document.getElementById('bfr-list');
- expect(list.textContent).toContain('__bfr_match_a__');
- expect(list.textContent).toContain('__bfr_match_b__');
- // Non-matching card should NOT be in the list
- expect(list.textContent.includes('__bfr_nomatch__')).toBe(false);
+ expect(document.getElementById('search-replace-row')).toBeVisible();
+ click('#search-replace-toggle');
+ await sleep(50);
+ expect(document.getElementById('search-replace-row')?.classList.contains('hidden')).toBe(true);
+ click('#search-replace-toggle');
+ await sleep(50);
+ expect(document.getElementById('search-replace-row')).toBeVisible();
});
it('replace all updates card bodies after confirmation', async () => {
@@ -74,29 +52,26 @@ describe('Board: Find/Replace', () => {
await window._reloadAll?.();
await sleep(200);
- openBoardFR();
- for (let i = 0; i < 40; i++) {
- if (document.getElementById('bfr-find')) break;
- await sleep(50);
- }
+ openSearchReplace();
+ await sleep(200);
- const findInp = document.getElementById('bfr-find');
- const replInp = document.getElementById('bfr-replace');
- if (!findInp || !replInp) return;
+ const searchInput = document.getElementById('search-input');
+ const replInput = document.getElementById('search-replace-input');
+ if (!searchInput || !replInput) return;
- findInp.value = 'hello';
- findInp.dispatchEvent(new Event('input', { bubbles: true }));
- await sleep(50);
- replInp.value = 'goodbye';
- replInp.dispatchEvent(new Event('input', { bubbles: true }));
+ searchInput.value = 'hello';
+ searchInput.dispatchEvent(new Event('input', { bubbles: true }));
+ await sleep(200);
+
+ replInput.value = 'goodbye';
+ replInput.dispatchEvent(new Event('input', { bubbles: true }));
await sleep(50);
- const applyBtn = document.getElementById('bfr-apply');
+ const applyBtn = document.getElementById('search-replace-all');
expect(applyBtn.disabled).toBe(false);
click(applyBtn);
await sleep(200);
- // The confirmation dialog should appear
const dialog = document.getElementById('dialog');
if (dialog?.classList.contains('open')) {
const okBtn = document.getElementById('dialog-ok');
@@ -104,7 +79,6 @@ describe('Board: Find/Replace', () => {
await sleep(600);
}
- // Verify card bodies were updated
const updated1 = await getCard(c1.id);
const updated2 = await getCard(c2.id);
expect(updated1.body).toBe('goodbye world goodbye');
@@ -116,20 +90,16 @@ describe('Board: Find/Replace', () => {
await window._reloadAll?.();
await sleep(200);
- openBoardFR();
- for (let i = 0; i < 40; i++) {
- if (document.getElementById('bfr-find')) break;
- await sleep(50);
- }
+ openSearchReplace();
+ await sleep(200);
- const findInp = document.getElementById('bfr-find');
- if (!findInp) return;
- findInp.value = 'some';
- findInp.dispatchEvent(new Event('input', { bubbles: true }));
- await sleep(50);
+ const searchInput = document.getElementById('search-input');
+ if (!searchInput) return;
+ searchInput.value = 'some';
+ searchInput.dispatchEvent(new Event('input', { bubbles: true }));
+ await sleep(100);
- const applyBtn = document.getElementById('bfr-apply');
- // Replace input is empty → button should be disabled
+ const applyBtn = document.getElementById('search-replace-all');
expect(applyBtn.disabled).toBe(true);
});
});