Add CSV export of scan results
The Review tab gets an Export CSV menu: "This view" exports the current status filter and search, "Entire scan" exports every scanned slave file. Each row carries status, match type, a recommendation, slave/master paths, the link target, the match reason, sizes, codec details for both sides, the quality verdict, notes, errors and timestamps. Rows are streamed in pages of 1000 so a large export never holds the database connection a running scan needs. The file starts with a UTF-8 BOM for Excel, and cells a spreadsheet would evaluate as formulas are prefixed with an apostrophe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5f895cab6a
commit
e8c534a3ae
6 files changed
+435
-6
No files matched your search
@@ -0,0 +1,162 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/cdnutter/playlink/internal/engine"
|
||||
"github.com/cdnutter/playlink/internal/probe"
|
||||
"github.com/cdnutter/playlink/internal/store"
|
||||
)
|
||||
|
||||
func TestExportCSV(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
root, _ := filepath.EvalSymlinks(t.TempDir())
|
||||
master, slave := filepath.Join(root, "Albums"), filepath.Join(root, "Singles")
|
||||
write := func(base, rel, content string) {
|
||||
p := filepath.Join(base, rel)
|
||||
os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Enough rows to need more than one export page.
|
||||
for i := 0; i < 1005; i++ {
|
||||
rel := fmt.Sprintf("Bulk/Album/%04d.flac", i)
|
||||
write(master, rel, "same")
|
||||
write(slave, rel, "same")
|
||||
}
|
||||
write(master, "Beyoncé/Lemonade/01 Pray You Catch Me.flac", "a")
|
||||
write(slave, "Beyoncé/Lemonade/01 Pray You Catch Me.flac", "b")
|
||||
write(slave, "=HYPERLINK(evil)/song.flac", "x") // a folder name a spreadsheet would run
|
||||
|
||||
st, err := store.Open(filepath.Join(root))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.DB.Close()
|
||||
eng := engine.New(st, probe.Prober{}, []string{root}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
p := store.Pair{Name: "Chris' Music", MasterRoot: master, SlaveRoot: slave}
|
||||
if _, err := eng.NormalizePair(&p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SavePair(ctx, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := eng.StartScan(p.ID, "test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for deadline := time.Now().Add(20 * time.Second); ; {
|
||||
if j := eng.Job(p.ID); j != nil && j.State != "running" {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("scan did not finish")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
|
||||
h := New(Config{}, st, eng, slog.New(slog.NewTextHandler(io.Discard, nil)), fstest.MapFS{}).Handler()
|
||||
get := func(url string, auth bool) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||
if auth {
|
||||
req.AddCookie(&http.Cookie{Name: cookieName, Value: "tok"})
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
url := fmt.Sprintf("/api/pairs/%d/export.csv", p.ID)
|
||||
if rec := get(url, false); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("export without a session must be refused, got %d", rec.Code)
|
||||
}
|
||||
if err := st.CreateFirstUser(ctx, "chris", "x"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, _ := st.UserByName(ctx, "chris")
|
||||
if err := st.CreateSession(ctx, hashToken("tok"), u.ID, time.Now().Add(time.Hour).Unix()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := get(url, true)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/csv") {
|
||||
t.Fatalf("content type %q", ct)
|
||||
}
|
||||
if cd := rec.Header().Get("Content-Disposition"); !strings.Contains(cd, `filename="playlink-Chris-Music-all-`) {
|
||||
t.Fatalf("content disposition %q", cd)
|
||||
}
|
||||
body := rec.Body.Bytes()
|
||||
if !bytes.HasPrefix(body, []byte{0xEF, 0xBB, 0xBF}) {
|
||||
t.Fatal("missing UTF-8 byte order mark")
|
||||
}
|
||||
rows, err := csv.NewReader(bytes.NewReader(body[3:])).ReadAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1+1005+2 {
|
||||
t.Fatalf("want header + 1007 rows (paging), got %d", len(rows))
|
||||
}
|
||||
if strings.Join(rows[0], ",") != strings.Join(exportHeader, ",") {
|
||||
t.Fatal("unexpected header")
|
||||
}
|
||||
col := map[string]int{}
|
||||
for i, h := range rows[0] {
|
||||
col[h] = i
|
||||
}
|
||||
seen := map[string][]string{}
|
||||
for _, r := range rows[1:] {
|
||||
seen[strings.TrimPrefix(r[col["slave_path"]], "'")] = r
|
||||
}
|
||||
if r := seen["Beyoncé/Lemonade/01 Pray You Catch Me.flac"]; r == nil || r[col["status"]] != "different" || r[col["recommendation"]] != "review" {
|
||||
t.Fatalf("non-ASCII row missing or wrong: %v", r)
|
||||
}
|
||||
if r := seen["=HYPERLINK(evil)/song.flac"]; r == nil || r[col["slave_path"]] != "'=HYPERLINK(evil)/song.flac" {
|
||||
t.Fatalf("a path starting with = must be neutralised, got %v", r)
|
||||
}
|
||||
if r := seen["Bulk/Album/0000.flac"]; r[col["recommendation"]] != "link (identical)" || r[col["match_type"]] != "same path" {
|
||||
t.Fatalf("identical row: %v", r)
|
||||
}
|
||||
|
||||
// Filters narrow the export exactly like the Review list.
|
||||
rec = get(url+"?status=different", true)
|
||||
rows, _ = csv.NewReader(bytes.NewReader(rec.Body.Bytes()[3:])).ReadAll()
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("status filter: want header + 1 row, got %d", len(rows))
|
||||
}
|
||||
rec = get(url+"?q=0100", true)
|
||||
rows, _ = csv.NewReader(bytes.NewReader(rec.Body.Bytes()[3:])).ReadAll()
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("search filter: want header + 1 row, got %d", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVSafe(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"=SUM(A1)": "'=SUM(A1)",
|
||||
"+1": "'+1",
|
||||
"-x": "'-x",
|
||||
"@cmd": "'@cmd",
|
||||
"Artist/Song": "Artist/Song",
|
||||
"": "",
|
||||
"Beyoncé/Halo": "Beyoncé/Halo",
|
||||
} {
|
||||
if got := csvSafe(in); got != want {
|
||||
t.Errorf("csvSafe(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user