This commit is contained in:
retoor 2025-06-24 16:20:38 +02:00
parent 3bc49ec40d
commit 1614776afa
2 changed files with 160 additions and 7 deletions

View File

@ -79,15 +79,23 @@
<div id="app">
<aside id="sidebar">
<h2>Tags</h2>
<!-- New search box -->
<input
type="text"
id="search"
placeholder="Search notes…"
style="width: calc(100% - 2rem); margin: .5rem 1rem; padding: .5rem;"
/>
<ul id="tag-list"></ul>
</aside>
<main>
<note-compose></note-compose>
<div id="note-grid"></div>
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<!--
<tag-input value="apple, banana, Cherry"></tag-input>
@ -432,8 +440,65 @@ class NoteCompose extends HTMLElement {
customElements.define('note-compose', NoteCompose);
/* tiny loaders unchanged */
async function loadNotes(tag){ const r=await fetch(tag?`${API_BASE}/notes?tag=${encodeURIComponent(tag)}`:`${API_BASE}/notes`); const ns=r.ok?await r.json():[]; const g=document.getElementById('note-grid'); g.innerHTML=''; ns.forEach(n=>{const c=document.createElement('note-card'); c.data=n; g.appendChild(c);} ); }
async function loadTags(){ const r=await fetch(`${API_BASE}/tags`); const ts=r.ok?await r.json():[]; const l=document.getElementById('tag-list'); l.innerHTML=''; ts.forEach(t=>{const li=document.createElement('li'); li.textContent=t.name||t; li.onclick=()=>{document.querySelectorAll('#tag-list li').forEach(x=>x.classList.toggle('active',x===li)); loadNotes(t.name||t);}; l.appendChild(li);} ); }
async function loadNotes(tag, search) {
let url = API_BASE + '/notes';
const params = [];
if (search) {
params.push(`search=${encodeURIComponent(search)}`);
} else if (tag) {
params.push(`tag=${encodeURIComponent(tag)}`);
}
if (params.length) {
url += '?' + params.join('&');
}
const res = await fetch(url);
const notes = res.ok ? await res.json() : [];
const grid = document.getElementById('note-grid');
grid.innerHTML = '';
notes.forEach(n => {
const card = document.createElement('note-card');
card.data = n;
grid.appendChild(card);
});
}
async function loadTags() {
const r = await fetch(`${API_BASE}/tags`);
const ts = r.ok ? await r.json() : [];
const ul = document.getElementById('tag-list');
ul.innerHTML = '';
ts.forEach(t => {
const li = document.createElement('li');
li.textContent = t.name || t;
li.onclick = () => {
// clear search box & active class
document.getElementById('search').value = '';
document.querySelectorAll('#tag-list li')
.forEach(x => x.classList.toggle('active', x === li));
loadNotes(t.name || t, null);
};
ul.appendChild(li);
});
}
// Wire up search box
document.getElementById('search').addEventListener('input', e => {
const q = e.target.value.trim();
// clear tag selection
document.querySelectorAll('#tag-list li')
.forEach(x => x.classList.remove('active'));
loadNotes(null, q || null);
});
// When notes change, refresh both lists
document.addEventListener('notes-changed', () => {
loadNotes();
loadTags();
});
// Initial load
loadNotes();
loadTags();
document.addEventListener('notes-changed', () => { loadNotes(); loadTags(); });
loadNotes(); loadTags();

96
main.py
View File

@ -78,11 +78,72 @@ if pathlib.Path(FRONTEND_DIR).exists():
app.mount("/assets", StaticFiles(directory=FRONTEND_DIR), name="assets")
@app.on_event("startup")
async def init_search_index():
db = dataset.connect(DB_URL)
# Create the FTS5 virtual table over notes.title and notes.body
db.query("""
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts
USING fts5(title, body, content='notes', content_rowid='id');
""")
# Triggers to keep it in sync
db.query("""
CREATE TRIGGER IF NOT EXISTS notes_ai AFTER INSERT ON notes
BEGIN
INSERT INTO notes_fts(rowid, title, body)
VALUES (new.id, new.title, new.body);
END;
""")
db.query("""
CREATE TRIGGER IF NOT EXISTS notes_ad AFTER DELETE ON notes
BEGIN
DELETE FROM notes_fts WHERE rowid = old.id;
END;
""")
db.query("""
CREATE TRIGGER IF NOT EXISTS notes_au AFTER UPDATE ON notes
BEGIN
UPDATE notes_fts SET title = new.title, body = new.body
WHERE rowid = old.id;
END;
""")
db.close()
@app.get("/api/search")
async def search_notes():
q = request.args.get("q", "")
tag = request.args.get("tag", "")
async with db_session() as db:
# Step 1: fulltext match → candidate note IDs
fts_rows = list(db.query("SELECT note_id FROM notes_fts WHERE notes_fts MATCH :query", query=q))
note_ids = {r["note_id"] for r in fts_rows}
if not note_ids:
return [] # early exit no matches
# Step 2: optional tag filtering (intersection)
if tag:
tag = [t.strip() for t in tag if t.strip()]
if not tag:
raise HTTPException(400, "Tag filter provided but empty after stripping")
tagged_ids = {
nt["note_id"]
for t in tag
for nt in db['note_tags'].find(tag=t)
}
note_ids &= tagged_ids
# Fetch & serialize
rows = [db['notes'].find_one(id=nid) for nid in note_ids]
rows.sort(key=lambda r: r["updated_at"], reverse=True)
return [await _serialize_note(r) for r in rows if r]
async def _serialize_note(row: Dict[str, Any]) -> Dict[str, Any]:
if not row:
return {}
note_id = row["id"]
async with db_session() as db:
atts = list(db['attachments'].find(note_id=note_id))
tags = [rt["tag"] for rt in db['note_tags'].find(note_id=note_id)]
@ -97,16 +158,35 @@ async def _serialize_note(row: Dict[str, Any]) -> Dict[str, Any]:
}
@app.get("/api/notes")
async def list_notes(tag: Optional[str] = None):
async def list_notes(tag: Optional[str] = None, search: Optional[str] = None):
"""
List notes. Supports:
- ?tag=foo to filter by tag
- ?search=term to full-text-search title+body
"""
async with db_session() as db:
if tag:
if search:
# FTS5 MATCH query
rows = list(db.query(
"SELECT notes.* FROM notes_fts "
"JOIN notes ON notes_fts.rowid = notes.id "
"WHERE notes_fts MATCH :q",
q=search
))
elif tag:
note_ids = [nt["note_id"] for nt in db['note_tags'].find(tag=tag)]
rows = [db['notes'].find_one(id=nid) for nid in note_ids]
else:
rows = list(db['notes'].all())
# sort & serialize
rows = [r for r in rows if r]
rows.sort(key=lambda r: r["created_at"], reverse=True)
return [await _serialize_note(r) for r in rows if r]
return [await _serialize_note(r) for r in rows]
@app.post("/api/notes")
async def create_note(payload: Dict[str, Any]):
@ -133,7 +213,15 @@ async def _upsert_note(note_id: Optional[int], payload: Dict[str, Any]):
db['notes'].update({"id": note_id, "title": title, "body": body, "updated_at": now}, ["id"])
db['attachments'].delete(note_id=note_id)
db['note_tags'].delete(note_id=note_id)
db.query("DELETE FROM notes_fts WHERE note_id = :nid", nid=note_id)
# (Re)insert into FTS table
db.query(
"INSERT INTO notes_fts (title, body, note_id) VALUES (:title, :body, :nid)",
title=title,
body=body,
nid=note_id,
)
for t in tags:
t = t.strip()
if not t: