Files
canon/internal/server/import.go
T
cdnutterandClaude Opus 5.5 dadf835be3 Import lists from CSV or text and match each entry
The list editor gets an Import button. A .csv/.tsv/.txt file or pasted
text is parsed on the server: header columns (Title, Year, Artist or
Director, Note, Rank), numbered lines and trailing "(1999)" years are
understood, and Canon's own export round-trips.

Each row is then searched with the list's provider and scored. A row is
matched automatically only when one result fits exactly and nothing else
fits as well; a year or artist in the file breaks ties, an exact title
beats a bracketed version like "(Live)", and "Artist - Title" lines are
tried both ways. Custom lists need a year or subtitle to be certain.
Everything else is shown for review with a recommended pick, the other
results, "Add as written", Skip, and a search-again box. Entries are
added in file order after the existing ones, with any notes from the
file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:54:02 -07:00

115 lines
3.1 KiB
Go

package server
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"unicode/utf8"
"github.com/cdnutter/canon/internal/importer"
"github.com/cdnutter/canon/internal/store"
)
const maxImportBytes = 2 << 20
// apiImportParse reads an uploaded CSV/TXT file, or pasted text, into rows.
func (s *Server) apiImportParse(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes+64<<10)
var data []byte
name := "pasted.txt"
if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/") {
f, hdr, err := r.FormFile("file")
if err != nil {
s.writeErr(w, store.ValidationError{Msg: "Choose a .csv or .txt file (up to 2 MB)."})
return
}
defer f.Close()
if data, err = io.ReadAll(f); err != nil {
s.writeErr(w, store.ValidationError{Msg: "That file is too big (2 MB at most)."})
return
}
name = hdr.Filename
} else {
var in struct {
Text string `json:"text"`
}
if err := decodeJSON(r, &in); err != nil {
s.writeErr(w, err)
return
}
data = []byte(in.Text)
}
rows, err := importer.Parse(data, name)
if err != nil {
if errors.Is(err, importer.ErrEmpty) {
err = store.ValidationError{Msg: "No titles found. Use one title per line, or a CSV with a Title column."}
} else {
err = store.ValidationError{Msg: err.Error()}
}
s.writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"rows": rows, "max": importer.MaxRows})
}
type importMatch struct {
Status string `json:"status"`
Results []searchResult `json:"results"`
}
// apiImportMatch searches for one imported row and says how sure the best
// result is.
func (s *Server) apiImportMatch(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
listID, err := pathID(r)
if err != nil {
s.writeErr(w, err)
return
}
var row importer.Row
if err := decodeJSON(r, &row); err != nil {
s.writeErr(w, err)
return
}
row.Title = strings.TrimSpace(row.Title)
if row.Title == "" || utf8.RuneCountInString(row.Title) > 300 {
writeError(w, http.StatusBadRequest, "Each entry needs a title.")
return
}
list, err := s.store.ListByID(ctx, listID)
if err != nil {
s.writeErr(w, err)
return
}
p, err := s.providers.For(ctx, list.Category)
if err != nil {
s.writeErr(w, err)
return
}
sctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
results, err := p.Search(sctx, importer.Query(row, list.Category))
if err != nil {
s.log.Warn("import search failed", "provider", p.Name(), "err", err)
writeError(w, http.StatusBadGateway, fmt.Sprintf("%s search failed: %v", p.Label(), err))
return
}
status, scored := importer.Rank(row, list.Category, results)
out := importMatch{Status: status, Results: []searchResult{}}
for _, sc := range scored[:min(len(scored), 6)] {
sr := searchResult{Result: sc.Result}
if sc.Provider == "deezer" && hasPreviews(list.Category) {
sr.PreviewRef = sc.ProviderID
}
if it, err := s.store.FindItemByProvider(ctx, listID, sc.Provider, sc.ProviderID); err == nil {
sr.ExistingRank = it.Rank
}
out.Results = append(out.Results, sr)
}
writeJSON(w, http.StatusOK, out)
}