Phase 2b: FTS5 search
- migration 0003: an FTS5 index over media_items as an external-content table, so the index stores no copy of the text. Triggers keep it in sync for every write path, including the scanner's bulk inserts. - migrate: splitStatements now tracks BEGIN...END depth, so CREATE TRIGGER bodies survive as one statement. Without this, the trigger's internal semicolons would tear it apart. - search: every whitespace term is quoted before it reaches FTS5. Unquoted input like a bare quote, a hyphen, or the word OR is valid FTS5 syntax and would either error or silently change the query's meaning. Tested against a list of hostile inputs, including a DROP TABLE attempt. - scoped through the same VisibleLibraryIDs chokepoint; an empty library set returns nothing, never everything. - web: search box in the topbar and a results view. Verified live: finds items across libraries, hostile input returns 200 with no results, index stays in sync on insert/update/delete.
This commit is contained in:
parent
c3b7db7114
commit
a2c2f7a7c1
11 changed files with 619 additions and 9 deletions
|
|
@ -19,6 +19,7 @@ import (
|
|||
"github.com/mithral/vellum/internal/media"
|
||||
"github.com/mithral/vellum/internal/modules/comics"
|
||||
"github.com/mithral/vellum/internal/modules/ebooks"
|
||||
"github.com/mithral/vellum/internal/search"
|
||||
"github.com/mithral/vellum/migrations"
|
||||
)
|
||||
|
||||
|
|
@ -77,6 +78,7 @@ func run() error {
|
|||
media.NewHandlers(db, log, authenticator, cfg.ConfigDir).Register(api.Mux())
|
||||
ebooks.NewHandlers(db, log, authenticator).Register(api.Mux())
|
||||
comics.NewHandlers(db, log, authenticator).Register(api.Mux())
|
||||
search.NewHandlers(db, log, authenticator).Register(api.Mux())
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Listen,
|
||||
|
|
|
|||
|
|
@ -98,20 +98,80 @@ func migrationNumber(name string) (int, error) {
|
|||
|
||||
// splitStatements splits a SQL file on semicolons that terminate a statement.
|
||||
//
|
||||
// This is not a general SQL parser. It is correct for the schema files this
|
||||
// project writes: no stored procedures, no BEGIN...END blocks, no semicolons
|
||||
// inside string literals. Comments are stripped first so that a ';' inside a
|
||||
// comment cannot split a statement.
|
||||
// Semicolons inside BEGIN...END blocks (trigger bodies) do not split, because
|
||||
// SQLite's CREATE TRIGGER syntax requires them and the whole trigger must be
|
||||
// executed as one statement. It is not a general SQL parser: it does not track
|
||||
// string literals beyond quotes, and it treats a bare END as closing a block,
|
||||
// so a CASE...END expression inside a trigger would be mis-split. The schema
|
||||
// files this project writes contain neither.
|
||||
func splitStatements(sqlText string) []string {
|
||||
clean := stripSQLComments(sqlText)
|
||||
var out []string
|
||||
for _, stmt := range strings.Split(stripSQLComments(sqlText), ";") {
|
||||
if s := strings.TrimSpace(stmt); s != "" {
|
||||
var b strings.Builder
|
||||
depth := 0
|
||||
|
||||
for i := 0; i < len(clean); {
|
||||
c := clean[i]
|
||||
|
||||
// Copy string literals verbatim so a ';' or keyword inside one is inert.
|
||||
if c == '\'' || c == '"' {
|
||||
b.WriteByte(c)
|
||||
quote := c
|
||||
i++
|
||||
for i < len(clean) {
|
||||
b.WriteByte(clean[i])
|
||||
if clean[i] == quote {
|
||||
i++
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if isWordByte(c) {
|
||||
start := i
|
||||
for i < len(clean) && isWordByte(clean[i]) {
|
||||
i++
|
||||
}
|
||||
switch strings.ToUpper(clean[start:i]) {
|
||||
case "BEGIN":
|
||||
depth++
|
||||
case "END":
|
||||
if depth > 0 {
|
||||
depth--
|
||||
}
|
||||
}
|
||||
b.WriteString(clean[start:i])
|
||||
continue
|
||||
}
|
||||
|
||||
if c == ';' && depth == 0 {
|
||||
if s := strings.TrimSpace(b.String()); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
b.Reset()
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
b.WriteByte(c)
|
||||
i++
|
||||
}
|
||||
|
||||
if s := strings.TrimSpace(b.String()); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isWordByte(c byte) bool {
|
||||
return c == '_' ||
|
||||
(c >= 'a' && c <= 'z') ||
|
||||
(c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9')
|
||||
}
|
||||
|
||||
// stripSQLComments removes -- line comments and /* */ block comments.
|
||||
func stripSQLComments(s string) string {
|
||||
var b strings.Builder
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ func TestMigrateAppliesAndIsIdempotent(t *testing.T) {
|
|||
if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
|
||||
t.Fatalf("read user_version: %v", err)
|
||||
}
|
||||
if version != 2 {
|
||||
t.Errorf("user_version = %d, want 2", version)
|
||||
if version != 3 {
|
||||
t.Errorf("user_version = %d, want 3", version)
|
||||
}
|
||||
|
||||
var tables int
|
||||
|
|
@ -122,3 +122,27 @@ CREATE TABLE b (id INTEGER);
|
|||
t.Errorf("statement 1 = %q", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitStatementsKeepsTriggerBodiesWhole(t *testing.T) {
|
||||
in := `
|
||||
CREATE TABLE t (id INTEGER, name TEXT);
|
||||
CREATE TRIGGER t_ai AFTER INSERT ON t BEGIN
|
||||
INSERT INTO t(name) VALUES (new.name);
|
||||
UPDATE t SET name = 'x' WHERE id = new.id;
|
||||
END;
|
||||
CREATE TABLE after_trigger (id INTEGER);
|
||||
`
|
||||
got := splitStatements(in)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %d statements, want 3:\n%#v", len(got), got)
|
||||
}
|
||||
if !strings.Contains(got[1], "CREATE TRIGGER") || !strings.Contains(got[1], "END") {
|
||||
t.Errorf("trigger was split apart: %q", got[1])
|
||||
}
|
||||
if n := strings.Count(got[1], ";"); n != 2 {
|
||||
t.Errorf("trigger body has %d semicolons, want 2 (the internal ones)", n)
|
||||
}
|
||||
if !strings.Contains(got[2], "after_trigger") {
|
||||
t.Errorf("statement after the trigger = %q", got[2])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
60
internal/search/handlers.go
Normal file
60
internal/search/handlers.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package search
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/mithral/vellum/internal/auth"
|
||||
"github.com/mithral/vellum/internal/httpx"
|
||||
)
|
||||
|
||||
// Handlers serves the search endpoint.
|
||||
type Handlers struct {
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
auth *auth.Authenticator
|
||||
}
|
||||
|
||||
// NewHandlers builds the search HTTP layer.
|
||||
func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator) *Handlers {
|
||||
return &Handlers{db: db, log: log, auth: a}
|
||||
}
|
||||
|
||||
// Register mounts the route.
|
||||
func (h *Handlers) Register(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/search", h.auth.Require(h.handleSearch))
|
||||
}
|
||||
|
||||
func (h *Handlers) handleSearch(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||
q := r.URL.Query().Get("q")
|
||||
if q == "" {
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"results": []Result{}, "query": ""})
|
||||
return
|
||||
}
|
||||
|
||||
limit := 50
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
// Scope the search to libraries the caller may read. This is the same
|
||||
// chokepoint every other read path uses.
|
||||
libs, err := auth.VisibleLibraryIDs(r.Context(), h.db, u)
|
||||
if err != nil {
|
||||
h.log.Error("visible libraries", "err", err)
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
results, err := Query(r.Context(), h.db, q, libs, limit)
|
||||
if err != nil {
|
||||
h.log.Error("search", "err", err)
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"results": results, "query": q})
|
||||
}
|
||||
84
internal/search/search.go
Normal file
84
internal/search/search.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// Package search queries the FTS5 index over media item titles and paths.
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Result is one matching item.
|
||||
type Result struct {
|
||||
ID int64 `json:"id"`
|
||||
LibraryID int64 `json:"library_id"`
|
||||
Kind string `json:"kind"`
|
||||
Path string `json:"path"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// Query searches visible libraries for a free-text string.
|
||||
//
|
||||
// Each whitespace-separated term becomes a quoted FTS5 token joined by
|
||||
// implicit AND. Quoting is not cosmetic: raw user input containing a quote, a
|
||||
// hyphen, or the word OR would otherwise be parsed as FTS5 query syntax and
|
||||
// raise an error, or silently change the meaning of the search.
|
||||
func Query(ctx context.Context, db *sql.DB, q string, libraryIDs []int64, limit int) ([]Result, error) {
|
||||
terms := ftsTerms(q)
|
||||
if len(terms) == 0 {
|
||||
return []Result{}, nil
|
||||
}
|
||||
if len(libraryIDs) == 0 {
|
||||
return []Result{}, nil
|
||||
}
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
placeholders := make([]string, len(libraryIDs))
|
||||
args := make([]any, 0, len(libraryIDs)+2)
|
||||
args = append(args, strings.Join(terms, " "))
|
||||
for i, id := range libraryIDs {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
args = append(args, limit)
|
||||
|
||||
query := `
|
||||
SELECT i.id, i.library_id, i.kind, i.path, i.title
|
||||
FROM media_search s
|
||||
JOIN media_items i ON i.id = s.rowid
|
||||
WHERE media_search MATCH ?
|
||||
AND i.missing = 0
|
||||
AND i.library_id IN (` + strings.Join(placeholders, ",") + `)
|
||||
ORDER BY bm25(media_search)
|
||||
LIMIT ?`
|
||||
|
||||
rows, err := db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search: query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Result{}
|
||||
for rows.Next() {
|
||||
var r Result
|
||||
if err := rows.Scan(&r.ID, &r.LibraryID, &r.Kind, &r.Path, &r.Title); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ftsTerms turns free text into a safe FTS5 expression: every term quoted, so
|
||||
// the result is always valid FTS5 and always means "all these words".
|
||||
func ftsTerms(q string) []string {
|
||||
var terms []string
|
||||
for _, field := range strings.Fields(q) {
|
||||
// A double quote inside a quoted FTS5 string is escaped by doubling it.
|
||||
escaped := strings.ReplaceAll(field, `"`, `""`)
|
||||
terms = append(terms, `"`+escaped+`"`)
|
||||
}
|
||||
return terms
|
||||
}
|
||||
200
internal/search/search_test.go
Normal file
200
internal/search/search_test.go
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mithral/vellum/internal/core"
|
||||
"github.com/mithral/vellum/migrations"
|
||||
)
|
||||
|
||||
func testDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := core.OpenDB(filepath.Join(t.TempDir(), "search.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func addLibrary(t *testing.T, db *sql.DB, name, kind string) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
err := db.QueryRowContext(context.Background(),
|
||||
`INSERT INTO libraries (name, kind, root_path) VALUES (?, ?, ?) RETURNING id`,
|
||||
name, kind, "/media/"+name).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func addItem(t *testing.T, db *sql.DB, libraryID int64, kind, path, title string) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
err := db.QueryRowContext(context.Background(),
|
||||
`INSERT INTO media_items (library_id, kind, path, title, sort_title) VALUES (?, ?, ?, ?, ?) RETURNING id`,
|
||||
libraryID, kind, path, title, title).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func titles(results []Result) []string {
|
||||
out := make([]string, len(results))
|
||||
for i, r := range results {
|
||||
out[i] = r.Title
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func contains(haystack []string, needle string) bool {
|
||||
for _, h := range haystack {
|
||||
if h == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestQueryFindsItems(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
lib := addLibrary(t, db, "ebooks", "ebooks")
|
||||
addItem(t, db, lib, "ebooks", "hobbit.epub", "The Hobbit")
|
||||
addItem(t, db, lib, "ebooks", "hail.epub", "Project Hail Mary")
|
||||
|
||||
res, err := Query(ctx, db, "hobbit", []int64{lib}, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("Query: %v", err)
|
||||
}
|
||||
if len(res) != 1 || res[0].Title != "The Hobbit" {
|
||||
t.Fatalf("results = %v", titles(res))
|
||||
}
|
||||
|
||||
// Multi-term is an implicit AND.
|
||||
res, err = Query(ctx, db, "project mary", []int64{lib}, 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res) != 1 || res[0].Title != "Project Hail Mary" {
|
||||
t.Errorf("multi-term results = %v", titles(res))
|
||||
}
|
||||
|
||||
// A term that matches nothing.
|
||||
res, err = Query(ctx, db, "nonexistent", []int64{lib}, 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res) != 0 {
|
||||
t.Errorf("expected no results, got %v", titles(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryIsScopedToLibraries(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
allowed := addLibrary(t, db, "allowed", "ebooks")
|
||||
hidden := addLibrary(t, db, "hidden", "ebooks")
|
||||
addItem(t, db, allowed, "ebooks", "a.epub", "Shared Word")
|
||||
addItem(t, db, hidden, "ebooks", "b.epub", "Shared Word Two")
|
||||
|
||||
res, err := Query(ctx, db, "shared", []int64{allowed}, 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res) != 1 || res[0].LibraryID != allowed {
|
||||
t.Fatalf("scoping failed: %v", titles(res))
|
||||
}
|
||||
|
||||
// No libraries means no results, never everything.
|
||||
res, err = Query(ctx, db, "shared", nil, 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res) != 0 {
|
||||
t.Errorf("empty library set returned %v", titles(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryHandlesHostileInput(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
lib := addLibrary(t, db, "ebooks", "ebooks")
|
||||
addItem(t, db, lib, "ebooks", "a.epub", "The Hobbit")
|
||||
|
||||
// None of these may raise an FTS5 syntax error.
|
||||
for _, q := range []string{
|
||||
`"`, `"unbalanced`, `OR`, `AND NOT`, `foo*`, `-hobbit`, `title:hobbit`,
|
||||
`hobbit OR`, `*`, `""`, `NEAR(`, `a"b`, `(); DROP TABLE users;--`,
|
||||
} {
|
||||
if _, err := Query(ctx, db, q, []int64{lib}, 50); err != nil {
|
||||
t.Errorf("Query(%q) errored: %v", q, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The injection attempt must not have dropped anything.
|
||||
var n int
|
||||
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM media_items`).Scan(&n); err != nil {
|
||||
t.Fatalf("media_items is gone: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("media_items count = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexStaysInSync(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
lib := addLibrary(t, db, "ebooks", "ebooks")
|
||||
|
||||
id := addItem(t, db, lib, "ebooks", "x.epub", "Original Title")
|
||||
if res, _ := Query(ctx, db, "original", []int64{lib}, 50); len(res) != 1 {
|
||||
t.Fatalf("insert did not reach the index")
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(ctx, `UPDATE media_items SET title = 'Changed Title' WHERE id = ?`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res, _ := Query(ctx, db, "changed", []int64{lib}, 50); len(res) != 1 {
|
||||
t.Error("update did not reach the index")
|
||||
}
|
||||
if res, _ := Query(ctx, db, "original", []int64{lib}, 50); len(res) != 0 {
|
||||
t.Error("stale entry survived the update")
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(ctx, `DELETE FROM media_items WHERE id = ?`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res, _ := Query(ctx, db, "changed", []int64{lib}, 50); len(res) != 0 {
|
||||
t.Error("delete did not reach the index")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingItemsAreExcluded(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
lib := addLibrary(t, db, "ebooks", "ebooks")
|
||||
id := addItem(t, db, lib, "ebooks", "gone.epub", "Vanished Book")
|
||||
|
||||
if _, err := db.ExecContext(ctx, `UPDATE media_items SET missing = 1 WHERE id = ?`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := Query(ctx, db, "vanished", []int64{lib}, 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res) != 0 {
|
||||
t.Errorf("missing item appeared in search: %v", titles(res))
|
||||
}
|
||||
}
|
||||
32
migrations/0003_search.sql
Normal file
32
migrations/0003_search.sql
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
-- 0003_search.sql — full-text search over items.
|
||||
--
|
||||
-- FTS5 with an external content table: the index stores no copy of the text,
|
||||
-- it points back at media_items, so the database does not double in size.
|
||||
-- Triggers keep the index in sync for every write path, including the scanner's
|
||||
-- bulk inserts and any future module's.
|
||||
|
||||
CREATE VIRTUAL TABLE media_search USING fts5(
|
||||
title,
|
||||
path,
|
||||
content='media_items',
|
||||
content_rowid='id',
|
||||
tokenize='unicode61 remove_diacritics 2'
|
||||
);
|
||||
|
||||
-- Populate the index from any rows that already exist.
|
||||
INSERT INTO media_search(media_search) VALUES ('rebuild');
|
||||
|
||||
CREATE TRIGGER media_items_ai AFTER INSERT ON media_items BEGIN
|
||||
INSERT INTO media_search(rowid, title, path) VALUES (new.id, new.title, new.path);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER media_items_ad AFTER DELETE ON media_items BEGIN
|
||||
INSERT INTO media_search(media_search, rowid, title, path)
|
||||
VALUES ('delete', old.id, old.title, old.path);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER media_items_au AFTER UPDATE ON media_items BEGIN
|
||||
INSERT INTO media_search(media_search, rowid, title, path)
|
||||
VALUES ('delete', old.id, old.title, old.path);
|
||||
INSERT INTO media_search(rowid, title, path) VALUES (new.id, new.title, new.path);
|
||||
END;
|
||||
|
|
@ -146,12 +146,23 @@ export const api = {
|
|||
|
||||
recent: () => request<{ progress: Progress[] }>('GET', '/api/v1/progress/recent'),
|
||||
|
||||
search: (q: string) =>
|
||||
request<{ results: SearchResult[]; query: string }>('GET', `/api/v1/search?q=${encodeURIComponent(q)}`),
|
||||
|
||||
users: () => request<{ users: User[] }>('GET', '/api/v1/users'),
|
||||
|
||||
createUser: (username: string, password: string, display_name: string, is_admin: boolean) =>
|
||||
request<{ user: User }>('POST', '/api/v1/users', { username, password, display_name, is_admin }),
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: number
|
||||
library_id: number
|
||||
kind: string
|
||||
path: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
library_id: number
|
||||
scanned: number
|
||||
|
|
|
|||
109
web/src/lib/components/SearchResults.svelte
Normal file
109
web/src/lib/components/SearchResults.svelte
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<script lang="ts">
|
||||
import { api, ApiError, type SearchResult } from '../api'
|
||||
import { go } from '../nav'
|
||||
|
||||
let { query }: { query: string } = $props()
|
||||
|
||||
let results = $state<SearchResult[]>([])
|
||||
let loading = $state(true)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
$effect(() => {
|
||||
void run(query)
|
||||
})
|
||||
|
||||
async function run(q: string) {
|
||||
loading = true
|
||||
error = null
|
||||
try {
|
||||
results = (await api.search(q)).results
|
||||
} catch (err) {
|
||||
error = err instanceof ApiError ? err.message : 'search failed'
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="center"><div class="spinner"></div></div>
|
||||
{:else if error}
|
||||
<div class="alert">{error}</div>
|
||||
{:else if results.length === 0}
|
||||
<div class="empty">
|
||||
<h3>No matches</h3>
|
||||
<p>Nothing in your libraries matches “{query}”.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="count muted">{results.length} result{results.length === 1 ? '' : 's'}</p>
|
||||
<div class="card list">
|
||||
{#each results as r (r.id)}
|
||||
<button class="row" onclick={() => go({ name: 'reader', itemId: r.id, title: r.title })}>
|
||||
<img class="thumb" src={api.coverUrl(r.id)} alt="" loading="lazy" onerror={(e) => (e.currentTarget as HTMLImageElement).remove()} />
|
||||
<span class="meta">
|
||||
<strong>{r.title}</strong>
|
||||
<span class="muted mono">{r.path}</span>
|
||||
</span>
|
||||
<span class="badge">{r.kind}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.center {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
.count {
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
.list {
|
||||
overflow: hidden;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.thumb {
|
||||
width: 28px;
|
||||
height: 42px;
|
||||
object-fit: cover;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
flex: none;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.meta strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
.meta .muted {
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -5,8 +5,16 @@
|
|||
import LibraryView from './LibraryView.svelte'
|
||||
import Reader from './Reader.svelte'
|
||||
import Users from './Users.svelte'
|
||||
import SearchResults from './SearchResults.svelte'
|
||||
|
||||
let kindFilter = $state<string | null>(null)
|
||||
let searchBox = $state('')
|
||||
|
||||
function submitSearch(event: SubmitEvent) {
|
||||
event.preventDefault()
|
||||
const q = searchBox.trim()
|
||||
if (q) go({ name: 'search', query: q })
|
||||
}
|
||||
|
||||
const kinds = $derived(
|
||||
$mediaTypes.filter((t) => $libraries.some((l) => l.kind === t.Kind)),
|
||||
|
|
@ -24,6 +32,8 @@
|
|||
return $libraries.find((l) => l.id === v.id)?.name ?? 'Library'
|
||||
case 'reader':
|
||||
return v.title
|
||||
case 'search':
|
||||
return 'Search'
|
||||
case 'users':
|
||||
return 'Users'
|
||||
case 'settings':
|
||||
|
|
@ -79,6 +89,15 @@
|
|||
<main class="main">
|
||||
<header class="topbar">
|
||||
<h1>{title($view)}</h1>
|
||||
<form class="search" onsubmit={submitSearch}>
|
||||
<input
|
||||
class="input"
|
||||
type="search"
|
||||
placeholder="Search titles and paths"
|
||||
bind:value={searchBox}
|
||||
aria-label="Search"
|
||||
/>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
|
|
@ -88,6 +107,8 @@
|
|||
<LibraryView libraryId={$view.id} />
|
||||
{:else if $view.name === 'reader'}
|
||||
<Reader itemId={$view.itemId} />
|
||||
{:else if $view.name === 'search'}
|
||||
<SearchResults query={$view.query} />
|
||||
{:else if $view.name === 'users'}
|
||||
<Users />
|
||||
{/if}
|
||||
|
|
@ -189,10 +210,16 @@
|
|||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
height: 52px;
|
||||
padding: 0 var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.search {
|
||||
flex: 1;
|
||||
max-width: 320px;
|
||||
}
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export type View =
|
|||
| { name: 'libraries' }
|
||||
| { name: 'library'; id: number }
|
||||
| { name: 'reader'; itemId: number; title: string }
|
||||
| { name: 'search'; query: string }
|
||||
| { name: 'users' }
|
||||
| { name: 'settings' }
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue