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>
This commit is contained in:
1 parent
aadacaff95
commit
dadf835be3
10 files changed
+1131
No files matched your search
@@ -0,0 +1,114 @@
|
||||
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)
|
||||
}
|
||||
@@ -146,6 +146,8 @@ func (s *Server) Handler() http.Handler {
|
||||
api("POST /api/items/{id}/refresh", s.apiRefreshItem)
|
||||
api("DELETE /api/items/{id}", s.apiDeleteItem)
|
||||
api("GET /api/search", s.apiSearch)
|
||||
api("POST /api/import/parse", s.apiImportParse)
|
||||
api("POST /api/lists/{id}/import/match", s.apiImportMatch)
|
||||
api("GET /api/settings", s.apiSettings)
|
||||
api("PUT /api/settings", s.apiUpdateSettings)
|
||||
api("POST /api/settings/avatar", s.apiUploadAvatar)
|
||||
|
||||
Reference in new issue
Block a user