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.Get("/job", s.handleJob)
|
||||||
r.Post("/job/cancel", s.handleCancelJob)
|
r.Post("/job/cancel", s.handleCancelJob)
|
||||||
r.Get("/matches", s.handleMatches)
|
r.Get("/matches", s.handleMatches)
|
||||||
|
r.Get("/letters", s.handleLetters)
|
||||||
r.Get("/export.csv", s.handleExport)
|
r.Get("/export.csv", s.handleExport)
|
||||||
r.Get("/groups", s.handleGroups)
|
r.Get("/groups", s.handleGroups)
|
||||||
r.Post("/bulk/preview", s.handleBulkPreview)
|
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})
|
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) {
|
func (s *Server) handleLinkIdentical(w http.ResponseWriter, r *http.Request) {
|
||||||
id, _ := idParam(r)
|
id, _ := idParam(r)
|
||||||
if !s.preAction(w, r, id, "link identical") {
|
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
|
f.Limit = 100
|
||||||
}
|
}
|
||||||
rows, err := s.DB.QueryContext(ctx, `SELECT `+matchCols+` FROM matches WHERE `+w+
|
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 {
|
if err != nil {
|
||||||
return nil, 0, err
|
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()
|
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
|
// 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
|
// path. Rows are read in pages so a slow download never holds the database
|
||||||
// connection (scans need it) for longer than one page.
|
// connection (scans need it) for longer than one page.
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"context"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestDecodeWarnings(t *testing.T) {
|
func TestDecodeWarnings(t *testing.T) {
|
||||||
old := decodeWarnings(`["Only 3 of 9 folders match."]`)
|
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)
|
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 { useInfo } from "../App";
|
||||||
import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes } from "../ui";
|
import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes } from "../ui";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { MatchRow } from "./Review";
|
import { LetterBar, MatchRow, letterOf } from "./Review";
|
||||||
|
|
||||||
export type Selection = Set<number>;
|
export type Selection = Set<number>;
|
||||||
|
|
||||||
@@ -52,14 +52,14 @@ export function AlbumView({
|
|||||||
sel: Selection;
|
sel: Selection;
|
||||||
setSel: (s: Selection) => void;
|
setSel: (s: Selection) => void;
|
||||||
}) {
|
}) {
|
||||||
const [page, setPage] = useState(0);
|
const [start, setStart] = useState(0);
|
||||||
useEffect(() => setPage(0), [status, query]);
|
useEffect(() => setStart(0), [status, query]);
|
||||||
const groups = useQuery({
|
const groups = useQuery({
|
||||||
queryKey: ["matches", "groups", pair.id, status, query],
|
queryKey: ["matches", "groups", pair.id, status, query],
|
||||||
queryFn: () => api.get<AlbumGroup[]>(`/pairs/${pair.id}/groups?status=${status}&q=${encodeURIComponent(query)}`),
|
queryFn: () => api.get<AlbumGroup[]>(`/pairs/${pair.id}/groups?status=${status}&q=${encodeURIComponent(query)}`),
|
||||||
});
|
});
|
||||||
const all = groups.data ?? [];
|
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 shownIDs = shown.flatMap((g) => g.ids);
|
||||||
const pageSelected = shownIDs.length > 0 && shownIDs.every((id) => sel.has(id));
|
const pageSelected = shownIDs.length > 0 && shownIDs.every((id) => sel.has(id));
|
||||||
|
|
||||||
@@ -80,6 +80,9 @@ export function AlbumView({
|
|||||||
|
|
||||||
return (
|
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">
|
<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">
|
<label className="flex items-center gap-3 border-b border-line bg-panel-2 px-4 py-2 text-xs text-muted">
|
||||||
<TriCheckbox
|
<TriCheckbox
|
||||||
@@ -102,13 +105,13 @@ export function AlbumView({
|
|||||||
{all.length > GROUP_PAGE && (
|
{all.length > GROUP_PAGE && (
|
||||||
<div className="flex items-center justify-between text-sm text-muted">
|
<div className="flex items-center justify-between text-sm text-muted">
|
||||||
<span>
|
<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>
|
</span>
|
||||||
<div className="flex gap-2">
|
<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
|
Previous
|
||||||
</Button>
|
</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
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,13 +35,58 @@ function hashParam(name: string) {
|
|||||||
|
|
||||||
const PAGE = 50;
|
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 }) {
|
export function Review({ pair }: { pair: Pair }) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [status, setStatus] = useState(() => hashParam("status") ?? (pair.summary.review > 0 ? "review" : "identical"));
|
const [status, setStatus] = useState(() => hashParam("status") ?? (pair.summary.review > 0 ? "review" : "identical"));
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [debounced, setDebounced] = 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 [confirmBulk, setConfirmBulk] = useState(false);
|
||||||
const [view, setView] = useState<"songs" | "albums">(() => {
|
const [view, setView] = useState<"songs" | "albums">(() => {
|
||||||
try {
|
try {
|
||||||
@@ -64,17 +109,23 @@ export function Review({ pair }: { pair: Pair }) {
|
|||||||
const t = setTimeout(() => setDebounced(q), 250);
|
const t = setTimeout(() => setDebounced(q), 250);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [q]);
|
}, [q]);
|
||||||
useEffect(() => setPage(0), [status, debounced]);
|
useEffect(() => setOffset(0), [status, debounced]);
|
||||||
// A selection only makes sense for the list it was made in.
|
// A selection only makes sense for the list it was made in.
|
||||||
useEffect(() => setSel(new Set()), [status, debounced, pair.id]);
|
useEffect(() => setSel(new Set()), [status, debounced, pair.id]);
|
||||||
|
|
||||||
const list = useQuery({
|
const list = useQuery({
|
||||||
queryKey: ["matches", pair.id, status, debounced, page],
|
queryKey: ["matches", pair.id, status, debounced, offset],
|
||||||
queryFn: () =>
|
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,
|
placeholderData: keepPreviousData,
|
||||||
enabled: status !== "duplicates",
|
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({
|
const bulk = useMutation({
|
||||||
mutationFn: () => api.post(`/pairs/${pair.id}/link-identical`),
|
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} />
|
<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">
|
<Card className="overflow-hidden">
|
||||||
{list.data && list.data.items.length > 0 && (
|
{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">
|
<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 && (
|
{list.data && list.data.total > PAGE && (
|
||||||
<div className="flex items-center justify-between text-sm text-muted">
|
<div className="flex items-center justify-between text-sm text-muted">
|
||||||
<span>
|
<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>
|
</span>
|
||||||
<div className="flex gap-2">
|
<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
|
Previous
|
||||||
</Button>
|
</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
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user