From a62b67e780dec815dc75f28116bb2ca70798988f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 19:45:50 +0000 Subject: [PATCH] Give search everything it was blind to, and one box to reach it from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #16 asked for a Spotlight over the whole library. Two things were in the way: search could not see the half of the library the LLM wrote, and the box it was typed into could search but not act. Summaries, action items and translations all live in analysis.result_text, which nothing indexed β€” so the words a user is most likely to remember reading were the words search could not find. A new FTS5 index covers them, and core/search.py answers a query from five places at once: transcripts, LLM output, names and aliases, tags, folders. A recording that matches in several is still one result, carrying the strongest match as its snippet and naming every place it was found. The route moves out of folders_tags.py, which was never where the app's central hub belonged. The header search box is now a command palette on Ctrl/Cmd+K, reachable mid-sentence from inside any text field. It searches as you type and runs any command in the app; / @ # narrow it to commands, recordings, or folders and tags β€” the prefixes the TUI palette already uses, with its fuzzy matcher ported so both rank the same way. Three smaller things the work turned up: - A recording renamed with an alias could not be found by that name. Search read the file name only β€” the one name the user had chosen was the one it ignored. - The old dropdown put snippets straight into innerHTML. They carry around the match and are otherwise raw transcript text, so a transcript discussing a script tag was one. - offset was applied per source and then merged, so paging skipped rows and repeated others. It now walks the merged list. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017gRFov5dsnRfubA6Rv8cX4 --- CHANGELOG.md | 34 ++ README.md | 4 +- backend/api/routes/folders_tags.py | 111 +------ backend/api/routes/search.py | 18 + backend/core/search.py | 318 ++++++++++++++++++ backend/main.py | 2 + backend/migrations.py | 47 +++ docs/ROADMAP.md | 3 + docs/doc.md | 58 ++++ frontend/index.html | 241 ++++++++++++-- frontend/js/app.js | 1 - frontend/js/command-palette.js | 434 +++++++++++++++++++++++++ frontend/js/commands.js | 190 +++++++++++ frontend/js/fuzzy.js | 60 ++++ frontend/js/library-init.js | 3 - frontend/js/main.js | 2 + frontend/js/search.js | 102 +----- tests/test_frontend_command_palette.py | 82 +++++ tests/test_search_unified.py | 220 +++++++++++++ 19 files changed, 1701 insertions(+), 229 deletions(-) create mode 100644 backend/api/routes/search.py create mode 100644 backend/core/search.py create mode 100644 frontend/js/command-palette.js create mode 100644 frontend/js/commands.js create mode 100644 frontend/js/fuzzy.js create mode 100644 tests/test_frontend_command_palette.py create mode 100644 tests/test_search_unified.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a4e0fd2..2a2fb2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,40 @@ Keep a Changelog format. ## [Unreleased] +### πŸ” One box for finding anything, and doing anything + +`Ctrl`/`⌘` + `K` opens a command palette from anywhere in the app β€” including +from inside a text field, since half of wanting it is being mid-sentence. It +replaces the header search box, which searched less and could do nothing. + +**Search now sees the half of the library it was blind to.** A recording's +summary, its action items and its translations are all LLM output living in one +column that nothing indexed, so the words a user is most likely to remember +reading were the words search could not find. A new FTS5 index covers them, and +`/api/search` answers from five places at once β€” transcripts, LLM output, file +names and aliases, tags and folders. A recording that matches in several places +is still one result, labelled with every place it was found in, and ranked by +the strongest of them. + +**The palette is also the app's command list.** Typing `/` filters commands β€” +go to a tab, ask your library, pick files, export a backup, open the console, +clear filters. `@` searches recordings, `#` searches folders and tags, and +anything else searches all of it at once. Commands, folders and tags are matched +in the browser and appear as you type; recordings come from the search endpoint, +debounced. The prefixes are the ones the terminal UI already uses, and the +matching is the same algorithm, ported. + +Smaller things that came with it: + +- **A recording renamed with an alias can now be found by that name.** Search + looked only at the original file name β€” the one name the user had chosen was + the one it ignored. +- **A search snippet is escaped before it is displayed.** Snippets carry + `` around the match and are otherwise raw transcript text, which the old + dropdown assigned straight to `innerHTML`. +- **`offset` walks the whole result list.** It used to be applied per source and + then merged, so paging skipped and repeated rows. + ### 🧹 A structural pass over the whole codebase A review of the repository against clean-architecture layering and clean-code diff --git a/README.md b/README.md index c41368e..6f14f86 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,9 @@ AmicoScript keeps everything local. OpenRouter and other hosted providers are supported too, behind an explicit opt-in - πŸ—£οΈ Speaker diarization (who said what) - 🌍 Real-time translation to English -- πŸ” Global search across transcripts +- πŸ” Command palette (`Ctrl`/`⌘` + `K`) β€” one box that searches transcripts, + LLM summaries, names, tags and folders at once, and runs any command in the + app. A recording appears once, labelled with where it matched - πŸ’¬ Ask your library β€” a question answered from every transcript at once, with citations that open the recording at the second it was said. Works on keyword search out of the box; name an embedding model and it searches by meaning diff --git a/backend/api/routes/folders_tags.py b/backend/api/routes/folders_tags.py index 466d5a0..676cf15 100644 --- a/backend/api/routes/folders_tags.py +++ b/backend/api/routes/folders_tags.py @@ -1,4 +1,4 @@ -"""Folder, tag, and search endpoints.""" +"""Folder and tag endpoints.""" import os import threading @@ -12,10 +12,9 @@ from fastapi import APIRouter, Depends, Form, HTTPException, Request from llm_providers import refusal_reason from models import Analysis, Folder, Recording, RecordingTag, Tag, Transcript -from search_query import build_fts_match from settings import get_llm_settings -from sqlalchemy import func, text -from sqlalchemy.exc import IntegrityError, OperationalError +from sqlalchemy import func +from sqlalchemy.exc import IntegrityError from sqlmodel import Session, select router = APIRouter() @@ -315,110 +314,6 @@ def remove_recording_tag(recording_id: str, tag_id: str, session: Session = Depe return {"ok": True} -@router.get("/api/search") -def search_library(q: str = "", limit: int = 20, offset: int = 0, session: Session = Depends(get_session)) -> list: - if not q.strip(): - return [] - - - - safe_limit = max(1, min(limit, 100)) - - # Escape LIKE wildcards so % and _ in the query are treated literally - q_like = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%" - - # Never hand raw user input to MATCH β€” see backend/search_query.py. - fts_expr = build_fts_match(q) - - try: - fts_rows = [] - if fts_expr: - fts_rows = session.exec( - text( - """ - SELECT t.recording_id, - snippet(transcript_fts, 0, '', '', '…', 20) AS snippet - FROM transcript_fts - JOIN transcript t ON transcript_fts.rowid = t.rowid - WHERE transcript_fts MATCH :q - ORDER BY rank - LIMIT :lim OFFSET :off - """ - ), - params={"q": fts_expr, "lim": safe_limit, "off": offset}, - ).all() - - meta_rows = session.exec( - text( - """ - SELECT DISTINCT r.id as recording_id, - CASE - WHEN f.name LIKE :ql ESCAPE '\\' THEN 'Folder: ' || f.name - WHEN t.name LIKE :ql ESCAPE '\\' THEN 'Tag: ' || t.name - ELSE 'Title: ' || r.filename - END as snippet - FROM recording r - LEFT JOIN folder f ON r.folder_id = f.id - LEFT JOIN recordingtag rt ON r.id = rt.recording_id - LEFT JOIN tag t ON rt.tag_id = t.id - WHERE r.filename LIKE :ql ESCAPE '\\' - OR f.name LIKE :ql ESCAPE '\\' - OR t.name LIKE :ql ESCAPE '\\' - ORDER BY r.filename - LIMIT :lim OFFSET :off - """ - ), - params={"ql": q_like, "lim": safe_limit, "off": offset}, - ).all() - - fts_ids = {r.recording_id: r.snippet for r in fts_rows} - ordered = list(fts_rows) - for r in meta_rows: - if r.recording_id not in fts_ids: - ordered.append(r) - rows = ordered[:safe_limit] - except OperationalError: - rows = session.exec( - text( - """ - SELECT DISTINCT r.id AS recording_id, - CASE - WHEN f.name LIKE :ql ESCAPE '\\' THEN 'Folder: ' || f.name - WHEN t.name LIKE :ql ESCAPE '\\' THEN 'Tag: ' || t.name - WHEN r.filename LIKE :ql ESCAPE '\\' THEN 'Title: ' || r.filename - ELSE COALESCE(substr(tr.full_text, 1, 100), 'Metadata match') - END AS snippet - FROM recording r - LEFT JOIN transcript tr ON r.id = tr.recording_id - LEFT JOIN folder f ON r.folder_id = f.id - LEFT JOIN recordingtag rt ON r.id = rt.recording_id - LEFT JOIN tag t ON rt.tag_id = t.id - WHERE r.filename LIKE :ql ESCAPE '\\' - OR tr.full_text LIKE :ql ESCAPE '\\' - OR f.name LIKE :ql ESCAPE '\\' - OR t.name LIKE :ql ESCAPE '\\' - ORDER BY r.filename - LIMIT :lim OFFSET :off - """ - ), - params={"ql": q_like, "lim": safe_limit, "off": offset}, - ).all() - - results = [] - for row in rows: - rec = session.get(Recording, row.recording_id) - if rec: - results.append( - { - "recording_id": row.recording_id, - "filename": rec.filename, - "duration": rec.duration, - "snippet": row.snippet, - } - ) - return results - - @router.post("/api/exit") async def api_exit(request: Request, token: str = ""): try: diff --git a/backend/api/routes/search.py b/backend/api/routes/search.py new file mode 100644 index 0000000..5262463 --- /dev/null +++ b/backend/api/routes/search.py @@ -0,0 +1,18 @@ +"""Global search β€” one query across transcripts, LLM output and metadata. + +The ranking and the SQL live in core/search.py; this is only the door. +""" + +from core.search import search_library +from db import get_session +from fastapi import APIRouter, Depends +from sqlmodel import Session + +router = APIRouter() + + +@router.get("/api/search") +def search( + q: str = "", limit: int = 20, offset: int = 0, session: Session = Depends(get_session) +) -> list: + return search_library(session, q, limit=limit, offset=offset) diff --git a/backend/core/search.py b/backend/core/search.py new file mode 100644 index 0000000..47410e9 --- /dev/null +++ b/backend/core/search.py @@ -0,0 +1,318 @@ +"""One query over everything the library knows about a recording. + +The search box used to see two things: the words in a transcript, and the +names of files, folders and tags. Everything the LLM produced β€” the summary +of a two-hour meeting, its action items, its translation β€” was invisible to +it, even though that is the part a user actually remembers reading. + +This module answers a query from five places at once: + +=========== ========================================================== +transcript the spoken words, FTS5 over ``transcript.full_text`` +summary LLM output, FTS5 over ``analysis.result_text`` +title the file name, or the alias the user renamed it to +tag the name of a tag on the recording, LLM-suggested or not +folder the name of the folder holding it +=========== ========================================================== + +A recording that matches in several places is still **one** result: the +caller gets a single row per recording, showing the strongest match, with +``matched_in`` listing every place the query was found. Hits from different +places therefore have to be ranked against each other, which is what +``_KIND_WEIGHT`` below is for. + +The ranking is deliberately dumb and explainable. Ordering within one place +is left to whatever produced it (FTS5's ``rank``, name order for the rest), +and a place never outranks a better one: matching in three places lifts a +result within its band, never above it. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +from models import Recording +from search_query import build_fts_match +from sqlalchemy import text +from sqlalchemy.exc import OperationalError +from sqlmodel import Session + +from utils.logging_utils import get_logger + +logger = get_logger("amicoscript.search") + +TRANSCRIPT = "transcript" +SUMMARY = "summary" +TITLE = "title" +TAG = "tag" +FOLDER = "folder" + +# Highest first: this is both the ranking order and the order the sources are +# consulted in, so the first place to claim a recording is the strongest one +# and gets to supply the snippet. +_KIND_WEIGHT = {TITLE: 5, TRANSCRIPT: 4, SUMMARY: 3, TAG: 2, FOLDER: 1} + +# One band per kind. A position inside a band is worth one point and a second +# matching place is worth _MULTI_BONUS, both far below _BAND so neither can +# push a result out of the band its best match earned. +_BAND = 10_000 +_MULTI_BONUS = 200 + +MAX_LIMIT = 100 +# How deep each source is read before merging. Deduplication means the merged +# list is shorter than the sum of its parts, so each source is read past what +# the caller asked for β€” but not without bound, since a one-letter query +# matches most of the library. +_MAX_SCAN = 500 + + +@dataclass +class _Hit: + """One recording, and every place this query was found in it.""" + + recording_id: str + kind: str + snippet: str + score: int + matched_in: list[str] = field(default_factory=list) + + +def _like_pattern(query: str) -> str: + """A LIKE pattern matching *query* literally, wildcards and all.""" + escaped = query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + return f"%{escaped}%" + + +def _rows(session: Session, sql: str, **params) -> list: + """Run *sql*, or return nothing if the table it needs is not there. + + An FTS index can be missing on a library whose migration was skipped (the + step logs and moves on rather than refusing to open the database). Search + degrading to the sources that do exist beats a 500. + """ + try: + return session.exec(text(sql), params=params).all() + except OperationalError as exc: + logger.warning("Search source unavailable, skipping it: %s", exc) + return [] + + +# --------------------------------------------------------------------------- +# The five sources +# --------------------------------------------------------------------------- +# Each returns rows of (recording_id, snippet), already in the order it wants +# to be ranked in. + + +def _transcript_hits(session: Session, expr: str, scan: int) -> list: + if not expr: + return [] + return _rows( + session, + """ + SELECT t.recording_id AS recording_id, + snippet(transcript_fts, 0, '', '', '…', 20) AS snippet + FROM transcript_fts + JOIN transcript t ON transcript_fts.rowid = t.rowid + WHERE transcript_fts MATCH :q + ORDER BY rank + LIMIT :scan + """, + q=expr, + scan=scan, + ) + + +def _summary_hits(session: Session, expr: str, scan: int) -> list: + """Matches in LLM output β€” summaries, action items, translations. + + Rows whose analysis has not answered yet hold an empty ``result_text`` and + match nothing, so no filter on status is needed here. + """ + if not expr: + return [] + return _rows( + session, + """ + SELECT a.recording_id AS recording_id, + snippet(analysis_fts, 0, '', '', '…', 20) AS snippet + FROM analysis_fts + JOIN analysis a ON analysis_fts.rowid = a.rowid + WHERE analysis_fts MATCH :q + ORDER BY rank + LIMIT :scan + """, + q=expr, + scan=scan, + ) + + +def _title_hits(session: Session, like: str, scan: int) -> list: + """File names, and the aliases users rename them to. + + The alias is what the library shows once it is set, so searching the + original file name alone would miss the only name the user has seen. + """ + return _rows( + session, + r""" + SELECT r.id AS recording_id, + CASE WHEN r.alias LIKE :ql ESCAPE '\' THEN r.alias ELSE r.filename END AS snippet + FROM recording r + WHERE r.filename LIKE :ql ESCAPE '\' OR r.alias LIKE :ql ESCAPE '\' + ORDER BY r.created_at DESC + LIMIT :scan + """, + ql=like, + scan=scan, + ) + + +def _tag_hits(session: Session, like: str, scan: int) -> list: + return _rows( + session, + r""" + SELECT rt.recording_id AS recording_id, t.name AS snippet + FROM tag t + JOIN recordingtag rt ON rt.tag_id = t.id + WHERE t.name LIKE :ql ESCAPE '\' + ORDER BY t.name + LIMIT :scan + """, + ql=like, + scan=scan, + ) + + +def _folder_hits(session: Session, like: str, scan: int) -> list: + return _rows( + session, + r""" + SELECT r.id AS recording_id, f.name AS snippet + FROM folder f + JOIN recording r ON r.folder_id = f.id + WHERE f.name LIKE :ql ESCAPE '\' + ORDER BY f.name + LIMIT :scan + """, + ql=like, + scan=scan, + ) + + +def _text_like_hits(session: Session, query: str, scan: int, table: str, column: str) -> list: + """Substring fallback for a text source whose FTS expression is empty. + + ``build_fts_match`` returns "" for a query FTS5 cannot express β€” one made + only of punctuation, say. LIKE has no such trouble, and finding ":-)" by + scanning is better than the search box going blank on it. The snippet is + cut around the match by hand, since there is no FTS index here to ask. + """ + return _rows( + session, + rf""" + SELECT s.recording_id AS recording_id, + substr(s.{column}, MAX(1, INSTR(LOWER(s.{column}), LOWER(:needle)) - 40), 120) AS snippet + FROM {table} s + WHERE s.{column} LIKE :ql ESCAPE '\' + LIMIT :scan + """, + ql=_like_pattern(query), + needle=query, + scan=scan, + ) + + +# --------------------------------------------------------------------------- +# Merge +# --------------------------------------------------------------------------- + + +def _collect(sources: list[tuple[str, list]]) -> list[_Hit]: + """Fold per-source rows into one hit per recording, ranked. + + *sources* arrives in ``_KIND_WEIGHT`` order, so the first source to see a + recording is its strongest match and the one that supplies the snippet. + """ + hits: dict[str, _Hit] = {} + for kind, rows in sources: + for position, row in enumerate(rows): + existing = hits.get(row.recording_id) + if existing is None: + hits[row.recording_id] = _Hit( + recording_id=row.recording_id, + kind=kind, + snippet=row.snippet or "", + score=_KIND_WEIGHT[kind] * _BAND - position, + matched_in=[kind], + ) + elif kind not in existing.matched_in: + existing.matched_in.append(kind) + existing.score += _MULTI_BONUS + + return sorted(hits.values(), key=lambda h: h.score, reverse=True) + + +def search_library( + session: Session, query: str, limit: int = 20, offset: int = 0 +) -> list[dict]: + """Search transcripts, LLM output and metadata for *query*. + + Returns one row per recording, strongest match first. ``snippet`` may + contain ```` around the matching words and is otherwise raw library + text, so callers must treat it as untrusted and escape it before display. + """ + query = (query or "").strip() + if not query: + return [] + + safe_limit = max(1, min(limit, MAX_LIMIT)) + safe_offset = max(0, offset) + scan = min(safe_limit + safe_offset, _MAX_SCAN) + + # Never hand raw user input to MATCH β€” see backend/search_query.py. + expr = build_fts_match(query) + like = _like_pattern(query) + + if expr: + transcripts = _transcript_hits(session, expr, scan) + summaries = _summary_hits(session, expr, scan) + else: + transcripts = _text_like_hits(session, query, scan, "transcript", "full_text") + summaries = _text_like_hits(session, query, scan, "analysis", "result_text") + + ranked = _collect([ + (TITLE, _title_hits(session, like, scan)), + (TRANSCRIPT, transcripts), + (SUMMARY, summaries), + (TAG, _tag_hits(session, like, scan)), + (FOLDER, _folder_hits(session, like, scan)), + ]) + + results = [] + for hit in ranked[safe_offset : safe_offset + safe_limit]: + recording = session.get(Recording, hit.recording_id) + if not recording: + continue + results.append( + { + "recording_id": hit.recording_id, + "filename": recording.filename, + "alias": recording.alias, + "duration": recording.duration, + "status": recording.status, + "kind": hit.kind, + "matched_in": hit.matched_in, + "snippet": _label(hit), + } + ) + return results + + +# The snippet of a metadata match is just a name, which says nothing on its +# own about *which* name it is. The labels predate this module and the TUI +# search screen still reads them, so they are kept exactly as they were. +_LABELS = {TITLE: "Title: ", TAG: "Tag: ", FOLDER: "Folder: "} + + +def _label(hit: _Hit) -> str: + return _LABELS.get(hit.kind, "") + hit.snippet diff --git a/backend/main.py b/backend/main.py index 67e927b..7f07b10 100644 --- a/backend/main.py +++ b/backend/main.py @@ -53,6 +53,7 @@ def _ensure_standard_streams() -> None: from api.routes.library_chat import router as library_chat_router from api.routes.llm import router as llm_router from api.routes.releases import router as releases_router +from api.routes.search import router as search_router from api.routes.settings import router as settings_router from api.routes.transcription import router as transcription_router from core.job_lifecycle import cleanup_loop, recover_interrupted_jobs @@ -132,6 +133,7 @@ async def _require_auth(request, call_next): app.include_router(library_router) app.include_router(library_chat_router) app.include_router(folders_tags_router) +app.include_router(search_router) app.include_router(benchmark_router) app.include_router(backup_router) diff --git a/backend/migrations.py b/backend/migrations.py index aba7634..4f5e128 100644 --- a/backend/migrations.py +++ b/backend/migrations.py @@ -193,6 +193,52 @@ def _m008_transcript_chunks(conn: Connection) -> None: conn.execute(text("INSERT INTO chunk_fts(chunk_fts) VALUES ('rebuild')")) +def _m009_analysis_fts(conn: Connection) -> None: + """FTS5 index over analysis.result_text, so search reaches LLM output. + + Everything the LLM writes about a recording β€” the summary, the action + items, a translation β€” lands in this one column. Without an index over it + the summary of a meeting was the one place the search box could not look, + which is backwards: the summary is usually what the user remembers. + """ + if "analysis" not in _tables(conn) or "result_text" not in _columns(conn, "analysis"): + logger.warning("Skipping analysis index: analysis.result_text does not exist") + return + + conn.execute(text(""" + CREATE VIRTUAL TABLE IF NOT EXISTS analysis_fts + USING fts5(result_text, content='analysis', content_rowid='rowid') + """)) + conn.execute(text(""" + CREATE TRIGGER IF NOT EXISTS analysis_ai + AFTER INSERT ON analysis BEGIN + INSERT INTO analysis_fts(rowid, result_text) + VALUES (new.rowid, new.result_text); + END + """)) + conn.execute(text(""" + CREATE TRIGGER IF NOT EXISTS analysis_ad + AFTER DELETE ON analysis BEGIN + INSERT INTO analysis_fts(analysis_fts, rowid, result_text) + VALUES ('delete', old.rowid, old.result_text); + END + """)) + # An analysis row is inserted empty and filled in when the LLM answers, so + # the update trigger is the one that indexes almost every summary. + conn.execute(text(""" + CREATE TRIGGER IF NOT EXISTS analysis_au + AFTER UPDATE ON analysis BEGIN + INSERT INTO analysis_fts(analysis_fts, rowid, result_text) + VALUES ('delete', old.rowid, old.result_text); + INSERT INTO analysis_fts(rowid, result_text) + VALUES (new.rowid, new.result_text); + END + """)) + # Every analysis in an existing library predates the triggers; without a + # rebuild none of them would be searchable until they were edited. + conn.execute(text("INSERT INTO analysis_fts(analysis_fts) VALUES ('rebuild')")) + + MIGRATIONS: list[tuple[int, str, Callable[[Connection], None]]] = [ (1, "folder.color_code", _m001_folder_color_code), (2, "recording.alias", _m002_recording_alias), @@ -202,6 +248,7 @@ def _m008_transcript_chunks(conn: Connection) -> None: (6, "analysis.auto_generated", _m006_analysis_auto_generated), (7, "recording.status_detail", _m007_recording_interrupted_reason), (8, "transcript_chunks", _m008_transcript_chunks), + (9, "analysis_fts", _m009_analysis_fts), ] SCHEMA_VERSION = MIGRATIONS[-1][0] diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 267ea23..22e1f4a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -18,6 +18,9 @@ The live roadmap is tracked on the **[GitHub Project board](https://github.com/u ### Shipped +- Unified global search & command palette β€” `Ctrl`/`⌘` + `K` searches + transcripts, LLM summaries, names, tags and folders in one box, and runs any + command in the app - Chat with your library β€” one question answered across every transcript, with citations that open the recording at the timestamp. Keyword retrieval works with no setup; naming an embedding model adds semantic search diff --git a/docs/doc.md b/docs/doc.md index 7f41cc7..5f35daa 100644 --- a/docs/doc.md +++ b/docs/doc.md @@ -143,6 +143,64 @@ top of a file is frontmatter. --- +### Global search & the command palette + +**GET /api/search?q=&limit=20&offset=0** + +One query, answered from everything the library knows about a recording: + +| `kind` | where the match was found | +| ------------ | -------------------------------------------------------- | +| `transcript` | the spoken words β€” FTS5 over `transcript.full_text` | +| `summary` | LLM output β€” FTS5 over `analysis.result_text` | +| `title` | the file name, or the alias it was renamed to | +| `tag` | the name of a tag on the recording | +| `folder` | the name of the folder holding it | + +```json +[ + { + "recording_id": "…", "filename": "standup.mp3", "alias": "Monday standup", + "duration": 743.0, "status": "done", + "kind": "summary", "matched_in": ["transcript", "summary"], + "snippet": "The team agreed to postpone the Helsinki launch." + } +] +``` + +A recording is returned **once** no matter how many places matched; `kind` is +the strongest of them and supplies the snippet, and `matched_in` lists them +all. Ranking is by kind in the order of the table above β€” a match in a better +place always outranks one in a worse place, and matching in several lifts a +result only within its own band. The ranking itself lives in +`backend/core/search.py`. + +`snippet` is library text with `` around the match. It is **not** escaped +β€” a transcript can contain anything β€” so a caller putting it in a page must +escape it and restore only those two tags, as `frontend/js/command-palette.js` +does. + +The query is never handed to FTS5 raw (see `backend/search_query.py`); a query +FTS5 cannot express at all, like `:-)`, falls back to a substring scan rather +than returning nothing. + +In the browser this is the **command palette**, opened with `Ctrl`/`⌘` + `K` +from anywhere, including from inside a text field. It searches as you type and +also runs any command in the app. A leading character narrows it, the same +prefixes the terminal UI uses: + +| prefix | shows | +| ------ | ------------------------------------------ | +| `/` | commands only | +| `@` | recordings only | +| `#` | folders and tags only | + +Commands, folders and tags are matched in the browser and appear instantly; +recordings come from `/api/search`, debounced. `Enter` on the last row opens +every result in the library rather than just the first few. + +--- + ### Library portability **GET /api/library/export?include_audio=true&ids=** diff --git a/frontend/index.html b/frontend/index.html index b37e443..74fe6f7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -322,33 +322,190 @@ box-shadow: 0 2px 8px 0 rgba(108, 99, 255, .08); } - /* global search dropdown */ - .search-dropdown { - position: absolute; - top: calc(100% + 4px); - left: 0; - right: 0; + /* command palette */ + #palette-overlay { + position: fixed; + inset: 0; + z-index: 9992; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 10vh 16px 16px; + background: rgba(15, 23, 42, 0.4); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + } + + #palette-overlay.hidden { + display: none; + } + + #palette-card { + width: 100%; + max-width: 640px; background: #fff; + border-radius: 16px; border: 1px solid #e2e8f0; - border-radius: 10px; - box-shadow: 0 8px 24px rgba(0, 0, 0, .10); - z-index: 100; - max-height: 320px; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, .3); + overflow: hidden; + animation: overlayIn 0.18s cubic-bezier(0.16, 1, 0.3, 1); + } + + #palette-input-row { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + border-bottom: 1px solid #f1f5f9; + } + + #palette-input { + flex: 1; + border: none; + outline: none; + font-size: 0.95rem; + color: #0f172a; + background: transparent; + } + + #palette-status { + font-size: 11px; + color: #94a3b8; + white-space: nowrap; + } + + #palette-results { + max-height: 56vh; overflow-y: auto; + padding: 6px 0; + margin: 0; + list-style: none; + } + + .palette-section { + padding: 8px 16px 4px; + font-size: 10px; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; + color: #94a3b8; } - .search-dropdown-item { - padding: 10px 14px; + .palette-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 16px; cursor: pointer; - border-bottom: 1px solid #f1f5f9; + border-left: 2px solid transparent; } - .search-dropdown-item:last-child { - border-bottom: none; + .palette-row.active { + background: #f5f4ff; + border-left-color: #6c63ff; } - .search-dropdown-item:hover { - background: #f8f7ff; + .palette-row.disabled { + opacity: .45; + cursor: default; + } + + .palette-icon { + width: 20px; + text-align: center; + color: #94a3b8; + font-size: 12px; + flex-shrink: 0; + } + + .palette-swatch { + width: 10px; + height: 10px; + border-radius: 3px; + margin: 0 5px; + flex-shrink: 0; + } + + .palette-text { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + } + + .palette-title { + font-size: 0.8125rem; + font-weight: 600; + color: #334155; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .palette-subtitle { + font-size: 11px; + color: #94a3b8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .palette-subtitle mark { + background: #fef3c7; + color: #92400e; + border-radius: 2px; + } + + .palette-meta { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + } + + .palette-badge { + font-size: 9px; + font-weight: 700; + letter-spacing: .04em; + text-transform: uppercase; + color: #6c63ff; + background: #ede9ff; + border-radius: 999px; + padding: 2px 7px; + } + + .palette-dur { + font-size: 11px; + color: #94a3b8; + font-variant-numeric: tabular-nums; + } + + .palette-empty { + padding: 20px 16px; + text-align: center; + font-size: 0.8125rem; + color: #94a3b8; + } + + #palette-footer { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding: 8px 16px; + border-top: 1px solid #f1f5f9; + background: #f8fafc; + font-size: 10px; + color: #94a3b8; + } + + #palette-footer kbd { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 4px; + padding: 1px 5px; + margin-right: 3px; + color: #6c63ff; } /* Entity edit modal styles */ @@ -1549,15 +1706,18 @@

Tags - @@ -2079,6 +2239,31 @@

P + + +