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) }