feat(core): v5.0.0 command palette + FTS5 search, v5.2.0 versioned migrations
FlowDeck CI / test (push) Failing after 12s
FlowDeck CI / docker (push) Skipped

- Command palette Ctrl+K/Ctrl+P (base.html): universal search, fuzzy
  highlight, keyboard nav, quick actions. openQuickFind now opens it.
- GET /api/search endpoint + search service: unified pages + databases
  search, FTS5 with LIKE fallback, trash excluded, user-scoped.
- app/migrations.py: lightweight versioned migration runner (schema_version
  table); baseline schema = v1, new steps applied in order.
- Migration v2: missing indexes (users.email, user_oauth_tokens, etc).
- Migration v3: FTS5 pages_fts + sync triggers + backfill.
- VERSION + app.main -> 5.0.0; CHANGELOG + ROADMAP updated.
- +8 tests (tests/test_search_migrations.py). 259 passed (3 pre-existing
  PDF/weasyprint env failures unrelated).
This commit is contained in:
2026-09-06 19:23:31 -04:00
parent 65559e5069
commit 0c271d5972
10 changed files with 796 additions and 21 deletions
+181 -2
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlowDeck — {% block title_prefix %}Home{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/app.css?v=4.8.1">
<link rel="stylesheet" href="/static/css/app.css?v=5.0.0">
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
<style>
/* ── Mobile responsive (v4.0.2) ── */
@@ -1236,7 +1236,7 @@
this.sidebarCollapsed = true;
window.location.href = url;
},
openQuickFind() { this.toast('Quick Find — Ctrl+K', 'info'); },
openQuickFind() { if (window.FlowDeckPalette) window.FlowDeckPalette.open(); },
// ── Tree state ──
expandedNodes: {},
@@ -1859,5 +1859,184 @@
{% include 'agent_panel.html' %}
<!-- ═══════════════════ COMMAND PALETTE (v5.0.0) — Ctrl+K / Ctrl+P ═══════════════════ -->
<style>
.cmd-palette-overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2100;display:flex;justify-content:center;padding-top:14vh;opacity:0;visibility:hidden;transition:opacity .12s ease,visibility .12s ease}
.cmd-palette-overlay.open{opacity:1;visibility:visible}
.cmd-palette{width:600px;max-width:92vw;max-height:60vh;display:flex;flex-direction:column;background:var(--bg-secondary,#232323);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:12px;box-shadow:0 24px 70px rgba(0,0,0,.6);overflow:hidden;transform:translateY(8px);transition:transform .15s ease}
.cmd-palette-overlay.open .cmd-palette{transform:translateY(0)}
.cmd-palette-input{width:100%;box-sizing:border-box;padding:16px 18px;background:transparent;border:none;outline:none;color:var(--text,#fff);font-size:16px}
.cmd-palette-input::placeholder{color:var(--text-dim,rgba(255,255,255,.4))}
.cmd-palette-list{overflow-y:auto;border-top:1px solid var(--border,rgba(255,255,255,.06))}
.cmd-palette-group{display:flex;align-items:center;gap:8px;padding:10px 18px 4px;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-dim,rgba(255,255,255,.4))}
.cmd-palette-item{display:flex;align-items:center;gap:10px;padding:9px 18px;cursor:pointer;font-size:14px;color:var(--text,#fff)}
.cmd-palette-item .cpi-icon{width:20px;text-align:center;flex:none;color:var(--text-dim,rgba(255,255,255,.5))}
.cmd-palette-item .cpi-main{flex:1;min-width:0}
.cmd-palette-item .cpi-title{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cmd-palette-item .cpi-sub{font-size:12px;color:var(--text-dim,rgba(255,255,255,.45));white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cmd-palette-item .cpi-sub mark{background:none;color:var(--accent,#2383E2);font-weight:600}
.cmd-palette-item .cpi-title mark{background:none;color:var(--accent,#2383E2);font-weight:600}
.cmd-palette-item .cpi-key{margin-left:auto;flex:none;font-size:11px;color:var(--text-dim,rgba(255,255,255,.35));font-family:monospace}
.cmd-palette-item.active{background:var(--bg-hover,#2e2e2e)}
.cmd-palette-empty{padding:22px 18px;color:var(--text-dim,rgba(255,255,255,.4));font-size:13px;text-align:center}
.cmd-palette-footer{display:flex;align-items:center;gap:14px;padding:8px 16px;border-top:1px solid var(--border,rgba(255,255,255,.06));font-size:11px;color:var(--text-dim,rgba(255,255,255,.4))}
.cmd-palette-footer .cpf-kbd{display:inline-flex;align-items:center;gap:3px}
.cmd-palette-footer b{color:var(--text,rgba(255,255,255,.8));font-weight:600}
</style>
<div id="fd-command-palette" class="cmd-palette-overlay" onclick="if(event.target===this)window.FlowDeckPalette&&window.FlowDeckPalette.close()">
<div class="cmd-palette" role="dialog" aria-label="Quick find">
<input id="fd-cp-input" class="cmd-palette-input" type="text"
placeholder="Search pages, databases, or type a command…"
autocomplete="off" spellcheck="false">
<div id="fd-cp-list" class="cmd-palette-list"></div>
<div class="cmd-palette-footer">
<span class="cpf-kbd"><b>↑</b> / <b>↓</b> navigate</span>
<span class="cpf-kbd"><b>↵</b> open</span>
<span class="cpf-kbd"><b>esc</b> close</span>
</div>
</div>
</div>
<script>
(function(){
var PALETTE_ACTIONS = [
{ id:'new-page', icon:'📄', title:'New page', sub:'Create a new page in the current workspace', key:'Ctrl N', run:function(){ return window.FlowDeck && window.FlowDeck.createPage ? (window.FlowDeck.createPage(), true) : false; } },
{ id:'goto-library', icon:'📚', title:'Go to Library', sub:'Recents · Favorites · Shared · Published', key:'', run:function(){ window.location.href='/library'; return true; } },
{ id:'goto-mytasks', icon:'✅', title:'My Tasks', sub:'Aggregated tasks across databases', key:'', run:function(){ window.location.href='/my-tasks'; return true; } },
{ id:'goto-trash', icon:'🗑️', title:'Trash', sub:'Recently deleted pages', key:'', run:function(){ window.location.href='/trash'; return true; } },
{ id:'goto-settings', icon:'⚙️', title:'Settings', sub:'Account, integrations, notifications', key:'', run:function(){ window.location.href='/accounts/settings'; return true; } },
{ id:'goto-help', icon:'❓', title:'Help & shortcuts', sub:'Keyboard shortcuts and guides', key:'', run:function(){ window.location.href='/help'; return true; } }
];
var overlay, input, list;
var state = { open:false, items:[], index:0, query:'', timer:null, actions:false };
function esc(s){ return String(s==null?'':s).replace(/[&<>"']/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]; }); }
function highlight(text, q){
text = esc(text);
var terms = (q||'').split(/\s+/).filter(function(t){return t;}).map(function(t){return t.toLowerCase();});
if(!terms.length) return text;
var lower = text.toLowerCase();
var out='', i=0;
for(var k=0;k<terms.length;k++){
var term=terms[k];
if(!term) continue;
var idx = lower.indexOf(term, i);
if(idx>=0){
out += text.slice(i, idx);
out += '<mark>'+text.slice(idx, idx+term.length)+'</mark>';
i = idx + term.length;
}
}
out += text.slice(i);
return out;
}
function render(){
if(!state.open) return;
if(!state.items.length){
list.innerHTML = '<div class="cmd-palette-empty">No results for “'+esc(state.query)+'”</div>';
return;
}
var groups = [];
var current = null;
state.items.forEach(function(item, idx){
var gkey = item.group || (item.type==='collection'?'Databases':(item.type==='page'?'Pages':'Actions'));
if(gkey!==current){ current=gkey; groups.push({gkey:gkey, items:[]}); }
groups[groups.length-1].items.push({item:item, idx:idx});
});
var html='';
groups.forEach(function(g){
html += '<div class="cmd-palette-group">'+esc(g.gkey)+'</div>';
g.items.forEach(function(gi){
var it=gi.item;
var cls = gi.idx===state.index ? 'cmd-palette-item active' : 'cmd-palette-item';
var icon = it.icon || (it.type==='page'?'📄':'📋');
var sub = it.subtitle || it.excerpt || '';
var key = it.key || '';
html += '<div class="'+cls+'" data-idx="'+gi.idx+'" onmouseenter="window.FlowDeckPalette.setIndex('+gi.idx+')" onclick="window.FlowDeckPalette.choose('+gi.idx+')">'
+ '<span class="cpi-icon">'+esc(icon)+'</span>'
+ '<span class="cpi-main"><span class="cpi-title">'+highlight(it.title, state.query)+'</span>'
+ (sub ? '<div class="cpi-sub">'+highlight(sub, state.query)+'</div>' : '')
+ '</span>'
+ (key ? '<span class="cpi-key">'+esc(key)+'</span>' : '')
+ '</div>';
});
});
list.innerHTML = html;
var active = list.querySelector('.cmd-palette-item.active');
if(active) active.scrollIntoView({block:'nearest'});
}
function runSearch(){
var q = input.value.trim();
state.query = q;
state.index = 0;
if(!q){
state.items = PALETTE_ACTIONS.map(function(a){ return Object.assign({group:'Actions', type:'action'}, a); });
state.actions = true;
render();
return;
}
state.actions = false;
fetch('/api/search?q='+encodeURIComponent(q), {headers:{'X-CSRF-Token': document.body.getAttribute('hx-headers') ? (JSON.parse(document.body.getAttribute('hx-headers'))['X-CSRF-Token']||'') : ''}})
.then(function(r){ return r.json(); })
.then(function(data){
if(input.value.trim()!==q) return; // stale
var pages=(data.pages||[]).slice(0,8), cols=(data.collections||[]).slice(0,6);
var items = pages.map(function(p){ return Object.assign({group:'Pages'}, p); })
.concat(cols.map(function(c){ return Object.assign({group:'Databases'}, c); }))
.concat(PALETTE_ACTIONS.map(function(a){ return Object.assign({group:'Actions', type:'action'}, a); }));
state.items = items;
render();
})
.catch(function(){ state.items=[]; render(); });
}
function open(){
if(state.open) return;
state.open=true; overlay.classList.add('open');
document.body.style.overflow='hidden';
setTimeout(function(){ input.focus(); }, 30);
runSearch();
}
function close(){
if(!state.open) return;
state.open=false; overlay.classList.remove('open');
document.body.style.overflow='';
input.blur();
}
function toggle(){ state.open ? close() : open(); }
function setIndex(i){ if(i>=0 && i<state.items.length){ state.index=i; render(); } }
function choose(i){
var it = state.items[i]; if(!it) return;
if(it.run){ var ok = it.run(); if(ok) close(); return; }
if(it.url){ window.location.href=it.url; close(); return; }
}
function onKey(e){
var tag=(document.activeElement&&document.activeElement.tagName)||'';
var mod = e.ctrlKey||e.metaKey;
if(mod && (e.key==='k'||e.key==='K'||e.key==='p'||e.key==='P')){
e.preventDefault(); toggle(); return;
}
if(!state.open) return;
if(e.key==='Escape'){ e.preventDefault(); close(); return; }
if(e.key==='ArrowDown'){ e.preventDefault(); setIndex(Math.min(state.index+1, state.items.length-1)); }
else if(e.key==='ArrowUp'){ e.preventDefault(); setIndex(Math.max(state.index-1, 0)); }
else if(e.key==='Enter'){ e.preventDefault(); choose(state.index); }
}
window.FlowDeckPalette = { open:open, close:close, toggle:toggle, setIndex:setIndex, choose:choose, getState:function(){return state;} };
document.addEventListener('DOMContentLoaded', function(){
overlay = document.getElementById('fd-command-palette');
input = document.getElementById('fd-cp-input');
list = document.getElementById('fd-cp-list');
if(!overlay) return;
input.addEventListener('input', function(){ clearTimeout(state.timer); state.timer=setTimeout(runSearch, 150); });
document.addEventListener('keydown', onKey);
});
})();
</script>
</body>
</html>