Jump the Review list to an artist by letter
An A–Z/# bar above the Songs and Albums lists jumps to the first artist starting with that letter. Songs come from a new /letters endpoint giving each leading character's offset; the song list now sorts case-insensitively like the album list so each letter stays together. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
095e294f4b
commit
00147aaa9e
5 files changed
+160
-16
No files matched your search
@@ -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") {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-7
@@ -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<number>;
|
||||
|
||||
@@ -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<AlbumGroup[]>(`/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 && (
|
||||
<LetterBar starts={all.map((g, i) => ({ letter: g.folder.charAt(0), offset: i }))} current={shown[0] && letterOf(shown[0].folder)} onJump={setStart} />
|
||||
)}
|
||||
<Card className="overflow-hidden">
|
||||
<label className="flex items-center gap-3 border-b border-line bg-panel-2 px-4 py-2 text-xs text-muted">
|
||||
<TriCheckbox
|
||||
@@ -102,13 +105,13 @@ export function AlbumView({
|
||||
{all.length > GROUP_PAGE && (
|
||||
<div className="flex items-center justify-between text-sm text-muted">
|
||||
<span>
|
||||
Albums {page * GROUP_PAGE + 1}–{Math.min((page + 1) * GROUP_PAGE, all.length)} of {all.length}
|
||||
Albums {start + 1}–{Math.min(start + GROUP_PAGE, all.length)} of {all.length}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" disabled={page === 0} onClick={() => setPage(page - 1)}>
|
||||
<Button size="sm" disabled={start === 0} onClick={() => setStart(Math.max(0, start - GROUP_PAGE))}>
|
||||
Previous
|
||||
</Button>
|
||||
<Button size="sm" disabled={(page + 1) * GROUP_PAGE >= all.length} onClick={() => setPage(page + 1)}>
|
||||
<Button size="sm" disabled={start + GROUP_PAGE >= all.length} onClick={() => setStart(start + GROUP_PAGE)}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -35,13 +35,58 @@ function hashParam(name: string) {
|
||||
|
||||
const PAGE = 50;
|
||||
|
||||
const LETTERS = [..."ABCDEFGHIJKLMNOPQRSTUVWXYZ", "#"];
|
||||
|
||||
// letterOf buckets a path by its first character: A–Z, or # for anything else.
|
||||
export function letterOf(path: string) {
|
||||
const c = path.charAt(0).toUpperCase();
|
||||
return c >= "A" && c <= "Z" ? c : "#";
|
||||
}
|
||||
|
||||
// LetterBar jumps a sorted list to the first artist starting with a letter.
|
||||
// starts gives the list offset of each leading character, in list order.
|
||||
export function LetterBar({ starts, current, onJump }: { starts: { letter: string; offset: number }[]; current?: string; onJump: (offset: number) => void }) {
|
||||
const first = new Map<string, number>();
|
||||
for (const s of starts) {
|
||||
const l = letterOf(s.letter);
|
||||
if (!first.has(l) || s.offset < first.get(l)!) first.set(l, s.offset);
|
||||
}
|
||||
return (
|
||||
<nav aria-label="Jump to artist by letter" className="flex flex-wrap gap-0.5">
|
||||
{LETTERS.map((l) => {
|
||||
const at = first.get(l);
|
||||
return (
|
||||
<button
|
||||
key={l}
|
||||
disabled={at === undefined}
|
||||
onClick={() => at !== undefined && onJump(at)}
|
||||
aria-current={current === l || undefined}
|
||||
title={at === undefined ? `No artists starting with ${l}` : `Jump to ${l === "#" ? "numbers and symbols" : l}`}
|
||||
className={cx(
|
||||
"min-w-7 rounded-md px-1.5 py-1 text-xs font-medium tabular-nums",
|
||||
at === undefined
|
||||
? "text-gray-700 cursor-default"
|
||||
: current === l
|
||||
? "bg-accent/15 text-white cursor-pointer"
|
||||
: "text-muted hover:bg-panel-2 hover:text-white cursor-pointer",
|
||||
)}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function Review({ pair }: { pair: Pair }) {
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [status, setStatus] = useState(() => hashParam("status") ?? (pair.summary.review > 0 ? "review" : "identical"));
|
||||
const [q, setQ] = useState("");
|
||||
const [debounced, setDebounced] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
// The list starts at an offset rather than a page so a letter jump lands at the top.
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [confirmBulk, setConfirmBulk] = useState(false);
|
||||
const [view, setView] = useState<"songs" | "albums">(() => {
|
||||
try {
|
||||
@@ -64,17 +109,23 @@ export function Review({ pair }: { pair: Pair }) {
|
||||
const t = setTimeout(() => setDebounced(q), 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [q]);
|
||||
useEffect(() => setPage(0), [status, debounced]);
|
||||
useEffect(() => setOffset(0), [status, debounced]);
|
||||
// A selection only makes sense for the list it was made in.
|
||||
useEffect(() => setSel(new Set()), [status, debounced, pair.id]);
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: ["matches", pair.id, status, debounced, page],
|
||||
queryKey: ["matches", pair.id, status, debounced, offset],
|
||||
queryFn: () =>
|
||||
api.get<Page<Match>>(`/pairs/${pair.id}/matches?status=${status}&q=${encodeURIComponent(debounced)}&limit=${PAGE}&offset=${page * PAGE}`),
|
||||
api.get<Page<Match>>(`/pairs/${pair.id}/matches?status=${status}&q=${encodeURIComponent(debounced)}&limit=${PAGE}&offset=${offset}`),
|
||||
placeholderData: keepPreviousData,
|
||||
enabled: status !== "duplicates",
|
||||
});
|
||||
const letters = useQuery({
|
||||
queryKey: ["matches", "letters", pair.id, status, debounced],
|
||||
queryFn: () => api.get<{ letter: string; offset: number }[]>(`/pairs/${pair.id}/letters?status=${status}&q=${encodeURIComponent(debounced)}`),
|
||||
placeholderData: keepPreviousData,
|
||||
enabled: status !== "duplicates" && view === "songs",
|
||||
});
|
||||
|
||||
const bulk = useMutation({
|
||||
mutationFn: () => api.post(`/pairs/${pair.id}/link-identical`),
|
||||
@@ -165,6 +216,9 @@ export function Review({ pair }: { pair: Pair }) {
|
||||
<AlbumView pair={pair} status={status} query={debounced} sel={sel} setSel={setSel} />
|
||||
) : (
|
||||
<>
|
||||
{list.data && list.data.total > PAGE && letters.data && (
|
||||
<LetterBar starts={letters.data} current={list.data.items[0] && letterOf(list.data.items[0].rel_slave)} onJump={setOffset} />
|
||||
)}
|
||||
<Card className="overflow-hidden">
|
||||
{list.data && list.data.items.length > 0 && (
|
||||
<label className="flex items-center gap-3 border-b border-line bg-panel-2 px-4 py-2 text-xs text-muted">
|
||||
@@ -196,13 +250,13 @@ export function Review({ pair }: { pair: Pair }) {
|
||||
{list.data && list.data.total > PAGE && (
|
||||
<div className="flex items-center justify-between text-sm text-muted">
|
||||
<span>
|
||||
{page * PAGE + 1}–{Math.min((page + 1) * PAGE, list.data.total)} of {list.data.total}
|
||||
{offset + 1}–{Math.min(offset + PAGE, list.data.total)} of {list.data.total}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" disabled={page === 0} onClick={() => setPage(page - 1)}>
|
||||
<Button size="sm" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE))}>
|
||||
Previous
|
||||
</Button>
|
||||
<Button size="sm" disabled={(page + 1) * PAGE >= list.data.total} onClick={() => setPage(page + 1)}>
|
||||
<Button size="sm" disabled={offset + PAGE >= list.data.total} onClick={() => setOffset(offset + PAGE)}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user