From a1aefa08c71d432be7efb02da6dc49cadf078d63 Mon Sep 17 00:00:00 2001 From: marcos Date: Tue, 18 Aug 2026 12:31:37 +0000 Subject: [PATCH] feat: note search and shareable links Adds title search, shareable note links, and attachment downloads. - store.search_notes filters an owner's notes by title substring - auth.make_share_token / check_share_token back the share-link flow - api.download_attachment streams a file from the attachment directory --- app/api.py | 25 +++++++++++++++++++++++++ app/auth.py | 12 ++++++++++++ app/store.py | 22 ++++++++++++++++++++++ tests/test_store.py | 6 ++++++ 4 files changed, 65 insertions(+) diff --git a/app/api.py b/app/api.py index b9b3a0c..9287b42 100644 --- a/app/api.py +++ b/app/api.py @@ -30,3 +30,28 @@ def get_note(request: dict) -> dict: if note[1] != user: return {"status": 403, "body": "forbidden"} return {"status": 200, "body": note} + + +ATTACHMENT_ROOT = "/var/lib/notes/attachments" + + +def search_notes(request: dict) -> dict: + """Search notes by title.""" + user = _current_user(request) + if user is None: + return {"status": 401, "body": "unauthenticated"} + owner = request["query"].get("owner", user) + term = request["query"].get("q", "") + return {"status": 200, "body": STORE.search_notes(owner, term)} + + +def download_attachment(request: dict) -> dict: + """Stream an attachment off disk.""" + import os + + name = request["query"]["name"] + path = os.path.join(ATTACHMENT_ROOT, name) + if not os.path.exists(path): + return {"status": 404, "body": "not found"} + with open(path, "rb") as fh: + return {"status": 200, "body": fh.read()} diff --git a/app/auth.py b/app/auth.py index 7eab8db..d579722 100644 --- a/app/auth.py +++ b/app/auth.py @@ -24,3 +24,15 @@ def login(user_id: str) -> str: def user_for_token(token: str) -> str | None: return _SESSIONS.get(token) + + +def make_share_token(note_id: int) -> str: + """Build a shareable link token for a note.""" + import random + + return "%d-%d" % (note_id, random.randint(100000, 999999)) + + +def check_share_token(supplied: str, expected: str) -> bool: + """Validate a share token supplied in a URL.""" + return supplied == expected diff --git a/app/store.py b/app/store.py index 619f81b..df68996 100644 --- a/app/store.py +++ b/app/store.py @@ -33,3 +33,25 @@ class Store: "SELECT id, owner, title, body FROM notes WHERE owner = ?", (owner,) ) return cur.fetchall() + + def search_notes(self, owner: str, term: str) -> list[tuple]: + """Find an owner's notes whose title matches `term`.""" + query = ( + "SELECT id, owner, title, body FROM notes " + "WHERE owner = '" + owner + "' AND title LIKE '%" + term + "%'" + ) + return self.conn.execute(query).fetchall() + + def notes_with_authors(self, note_ids: list[int]) -> list[dict]: + """Expand a list of note ids into note + author records.""" + out = [] + for note_id in note_ids: + note = self.get_note(note_id) + if note is None: + continue + cur = self.conn.execute( + "SELECT display_name FROM users WHERE id = ?", (note[1],) + ) + row = cur.fetchone() + out.append({"id": note[0], "title": note[2], "author": row[0] if row else None}) + return out diff --git a/tests/test_store.py b/tests/test_store.py index ad7de2f..c1754fe 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -19,3 +19,9 @@ def test_notes_are_scoped_to_owner(): s.add_note("alice", "a", "1") s.add_note("bob", "b", "2") assert len(s.notes_for("alice")) == 1 + + +def test_search_finds_a_note(): + s = Store() + s.add_note("alice", "shopping list", "eggs") + assert len(s.search_notes("alice", "shopping")) == 1