Updated.
This commit is contained in:
parent
3bc49ec40d
commit
1614776afa
@ -79,15 +79,23 @@
|
|||||||
<div id="app">
|
<div id="app">
|
||||||
<aside id="sidebar">
|
<aside id="sidebar">
|
||||||
<h2>Tags</h2>
|
<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>
|
<ul id="tag-list"></ul>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<note-compose></note-compose>
|
<note-compose></note-compose>
|
||||||
<div id="note-grid"></div>
|
<div id="note-grid"></div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
<!--
|
<!--
|
||||||
<tag-input value="apple, banana, Cherry"></tag-input>
|
<tag-input value="apple, banana, Cherry"></tag-input>
|
||||||
@ -432,8 +440,65 @@ class NoteCompose extends HTMLElement {
|
|||||||
customElements.define('note-compose', NoteCompose);
|
customElements.define('note-compose', NoteCompose);
|
||||||
|
|
||||||
/* tiny loaders – unchanged */
|
/* 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 loadNotes(tag, search) {
|
||||||
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);} ); }
|
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(); });
|
document.addEventListener('notes-changed', () => { loadNotes(); loadTags(); });
|
||||||
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.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]:
|
async def _serialize_note(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
if not row:
|
if not row:
|
||||||
return {}
|
return {}
|
||||||
note_id = row["id"]
|
note_id = row["id"]
|
||||||
|
|
||||||
async with db_session() as db:
|
async with db_session() as db:
|
||||||
atts = list(db['attachments'].find(note_id=note_id))
|
atts = list(db['attachments'].find(note_id=note_id))
|
||||||
tags = [rt["tag"] for rt in db['note_tags'].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")
|
@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:
|
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)]
|
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]
|
rows = [db['notes'].find_one(id=nid) for nid in note_ids]
|
||||||
else:
|
else:
|
||||||
rows = list(db['notes'].all())
|
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)
|
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")
|
@app.post("/api/notes")
|
||||||
async def create_note(payload: Dict[str, Any]):
|
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['notes'].update({"id": note_id, "title": title, "body": body, "updated_at": now}, ["id"])
|
||||||
db['attachments'].delete(note_id=note_id)
|
db['attachments'].delete(note_id=note_id)
|
||||||
db['note_tags'].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:
|
for t in tags:
|
||||||
t = t.strip()
|
t = t.strip()
|
||||||
if not t:
|
if not t:
|
||||||
|
Loading…
Reference in New Issue
Block a user