diff --git a/internal/api/server.go b/internal/api/server.go index 440bbc9..0e37040 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -93,6 +93,7 @@ func (s *Server) Handler() http.Handler { r.Get("/job", s.handleJob) r.Post("/job/cancel", s.handleCancelJob) r.Get("/matches", s.handleMatches) + r.Get("/letters", s.handleLetters) r.Get("/export.csv", s.handleExport) r.Get("/groups", s.handleGroups) r.Post("/bulk/preview", s.handleBulkPreview) @@ -474,6 +475,17 @@ func (s *Server) handleMatches(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"items": ms, "total": total}) } +func (s *Server) handleLetters(w http.ResponseWriter, r *http.Request) { + id, _ := idParam(r) + q := r.URL.Query() + ls, err := s.st.MatchLetters(r.Context(), id, store.MatchFilter{Status: q.Get("status"), Query: q.Get("q")}) + if err != nil { + writeErr(w, 500, err) + return + } + writeJSON(w, 200, ls) +} + func (s *Server) handleLinkIdentical(w http.ResponseWriter, r *http.Request) { id, _ := idParam(r) if !s.preAction(w, r, id, "link identical") { diff --git a/internal/store/models.go b/internal/store/models.go index 36700dc..0c3d978 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -432,7 +432,7 @@ func (s *Store) Matches(ctx context.Context, pairID int64, f MatchFilter) ([]Mat f.Limit = 100 } rows, err := s.DB.QueryContext(ctx, `SELECT `+matchCols+` FROM matches WHERE `+w+ - ` ORDER BY rel_slave LIMIT ? OFFSET ?`, append(args, f.Limit, f.Offset)...) + ` ORDER BY rel_slave COLLATE NOCASE, rel_slave LIMIT ? OFFSET ?`, append(args, f.Limit, f.Offset)...) if err != nil { return nil, 0, err } @@ -448,6 +448,39 @@ func (s *Store) Matches(ctx context.Context, pairID int64, f MatchFilter) ([]Mat return out, total, rows.Err() } +// LetterStart is where the paths beginning with one (lowercased) character +// start in the Matches listing of a filter. +type LetterStart struct { + Letter string `json:"letter"` + Offset int `json:"offset"` +} + +// MatchLetters returns, in listing order, the offset of the first match for +// each leading character of rel_slave, for jumping to an artist by letter. +// Grouping on lower() of the first character keeps each group contiguous +// under the NOCASE order Matches uses. +func (s *Store) MatchLetters(ctx context.Context, pairID int64, f MatchFilter) ([]LetterStart, error) { + w, args := matchWhere(pairID, f) + rows, err := s.DB.QueryContext(ctx, `SELECT lower(substr(rel_slave, 1, 1)) AS l, COUNT(*) FROM matches WHERE `+w+ + ` GROUP BY l ORDER BY l`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []LetterStart{} + offset := 0 + for rows.Next() { + var l string + var n int + if err := rows.Scan(&l, &n); err != nil { + return nil, err + } + out = append(out, LetterStart{Letter: l, Offset: offset}) + offset += n + } + return out, rows.Err() +} + // ExportMatches calls fn for every match of the filter, ordered by slave // path. Rows are read in pages so a slow download never holds the database // connection (scans need it) for longer than one page. diff --git a/internal/store/store_test.go b/internal/store/store_test.go index a5e2957..a71ac26 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -1,6 +1,11 @@ package store -import "testing" +import ( + "context" + "reflect" + "strings" + "testing" +) func TestDecodeWarnings(t *testing.T) { old := decodeWarnings(`["Only 3 of 9 folders match."]`) @@ -15,3 +20,40 @@ func TestDecodeWarnings(t *testing.T) { t.Fatalf("empty list should stay an empty (non-nil) list, got %#v", ws) } } + +func TestMatchLetters(t *testing.T) { + ctx := context.Background() + st, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.DB.Close() }) + p := Pair{Name: "p", MasterRoot: "/m", SlaveRoot: "/s", RetentionDays: 30} + if err := st.SavePair(ctx, &p); err != nil { + t.Fatal(err) + } + for _, rel := range []string{"beck/a.flac", "ABBA/a.flac", "Air/a.flac", "2Pac/a.flac", "Björk/a.flac", "abba/b.flac"} { + m := Match{PairID: p.ID, RelSlave: rel, Status: StatusUnmatched, Kind: KindNone} + if err := st.UpsertMatch(ctx, &m); err != nil { + t.Fatal(err) + } + } + ls, err := st.MatchLetters(ctx, p.ID, MatchFilter{}) + if err != nil { + t.Fatal(err) + } + want := []LetterStart{{"2", 0}, {"a", 1}, {"b", 4}} + if !reflect.DeepEqual(ls, want) { + t.Fatalf("letters = %+v, want %+v", ls, want) + } + // The offsets must agree with the listing order, whatever the case. + ms, _, err := st.Matches(ctx, p.ID, MatchFilter{Limit: 10}) + if err != nil { + t.Fatal(err) + } + for _, l := range ls { + if got := strings.ToLower(ms[l.Offset].RelSlave[:1]); got != l.Letter { + t.Fatalf("offset %d starts with %q, want %q (listing %v)", l.Offset, got, l.Letter, ms) + } + } +} diff --git a/web/src/pages/Bulk.tsx b/web/src/pages/Bulk.tsx index 50e71a8..00ed472 100644 --- a/web/src/pages/Bulk.tsx +++ b/web/src/pages/Bulk.tsx @@ -5,7 +5,7 @@ import { api, type AlbumGroup, type BulkAction, type BulkPreview, type Match, ty import { useInfo } from "../App"; import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes } from "../ui"; import { useToast } from "../toast"; -import { MatchRow } from "./Review"; +import { LetterBar, MatchRow, letterOf } from "./Review"; export type Selection = Set; @@ -52,14 +52,14 @@ export function AlbumView({ sel: Selection; setSel: (s: Selection) => void; }) { - const [page, setPage] = useState(0); - useEffect(() => setPage(0), [status, query]); + const [start, setStart] = useState(0); + useEffect(() => setStart(0), [status, query]); const groups = useQuery({ queryKey: ["matches", "groups", pair.id, status, query], queryFn: () => api.get(`/pairs/${pair.id}/groups?status=${status}&q=${encodeURIComponent(query)}`), }); const all = groups.data ?? []; - const shown = all.slice(page * GROUP_PAGE, (page + 1) * GROUP_PAGE); + const shown = all.slice(start, start + GROUP_PAGE); const shownIDs = shown.flatMap((g) => g.ids); const pageSelected = shownIDs.length > 0 && shownIDs.every((id) => sel.has(id)); @@ -80,6 +80,9 @@ export function AlbumView({ return ( <> + {all.length > GROUP_PAGE && ( + ({ letter: g.folder.charAt(0), offset: i }))} current={shown[0] && letterOf(shown[0].folder)} onJump={setStart} /> + )}