Updated.
This commit is contained in:
parent
3bc49ec40d
commit
1614776afa
@ -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
96
main.py
@ -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: full‑text 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:
|
||||
|
Loading…
Reference in New Issue
Block a user