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>
115 lines
3.1 KiB
Go
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)
|
|
}
|