// SPDX-License-Identifier: AGPL-3.0-or-later package handler import ( "encoding/csv" "encoding/json" "fmt" "io" "log/slog" "net/http" "strings" "kode.naiv.no/olemd/favoritter/internal/middleware" "kode.naiv.no/olemd/favoritter/internal/render" ) const maxExportFaves = 100000 // ExportFave is the JSON representation for export/import. type ExportFave struct { Description string `json:"description"` URL string `json:"url,omitempty"` Notes string `json:"notes,omitempty"` Privacy string `json:"privacy"` Tags []string `json:"tags,omitempty"` CreatedAt string `json:"created_at"` } // handleExportPage shows the export options page. func (h *Handler) handleExportPage(w http.ResponseWriter, r *http.Request) { h.deps.Renderer.Page(w, r, "export", render.PageData{ Title: "Eksporter favoritter", }) } // handleExportJSON exports the user's faves as JSON. func (h *Handler) handleExportJSON(w http.ResponseWriter, r *http.Request) { user := middleware.UserFromContext(r.Context()) faves, _, err := h.deps.Faves.ListByUser(user.ID, maxExportFaves, 0) if err != nil { slog.Error("export: list faves error", "error", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } if err := h.deps.Faves.LoadTags(faves); err != nil { slog.Error("export: load tags error", "error", err) } export := make([]ExportFave, len(faves)) for i, f := range faves { tags := make([]string, len(f.Tags)) for j, t := range f.Tags { tags[j] = t.Name } export[i] = ExportFave{ Description: f.Description, URL: f.URL, Notes: f.Notes, Privacy: f.Privacy, Tags: tags, CreatedAt: f.CreatedAt.Format("2006-01-02T15:04:05Z"), } } w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Disposition", "attachment; filename=favoritter.json") enc := json.NewEncoder(w) enc.SetIndent("", " ") enc.Encode(export) } // handleExportCSV exports the user's faves as CSV. func (h *Handler) handleExportCSV(w http.ResponseWriter, r *http.Request) { user := middleware.UserFromContext(r.Context()) faves, _, err := h.deps.Faves.ListByUser(user.ID, maxExportFaves, 0) if err != nil { slog.Error("export: list faves error", "error", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } if err := h.deps.Faves.LoadTags(faves); err != nil { slog.Error("export: load tags error", "error", err) } w.Header().Set("Content-Type", "text/csv; charset=utf-8") w.Header().Set("Content-Disposition", "attachment; filename=favoritter.csv") cw := csv.NewWriter(w) cw.Write([]string{"description", "url", "notes", "privacy", "tags", "created_at"}) for _, f := range faves { tags := make([]string, len(f.Tags)) for j, t := range f.Tags { tags[j] = t.Name } cw.Write([]string{ f.Description, f.URL, f.Notes, f.Privacy, strings.Join(tags, ","), f.CreatedAt.Format("2006-01-02T15:04:05Z"), }) } cw.Flush() } // handleImportPage shows the import page. func (h *Handler) handleImportPage(w http.ResponseWriter, r *http.Request) { h.deps.Renderer.Page(w, r, "import", render.PageData{ Title: "Importer favoritter", }) } // handleImportPost processes an uploaded JSON or CSV file. func (h *Handler) handleImportPost(w http.ResponseWriter, r *http.Request) { user := middleware.UserFromContext(r.Context()) if err := r.ParseMultipartForm(h.deps.Config.MaxUploadSize); err != nil { h.flash(w, r, "import", "Filen er for stor.", "error", nil) return } file, header, err := r.FormFile("file") if err != nil { h.flash(w, r, "import", "Velg en fil å importere.", "error", nil) return } defer file.Close() var faves []ExportFave filename := strings.ToLower(header.Filename) switch { case strings.HasSuffix(filename, ".json"): faves, err = parseImportJSON(file) case strings.HasSuffix(filename, ".csv"): faves, err = parseImportCSV(file) default: h.flash(w, r, "import", "Ugyldig filformat. Bruk JSON eller CSV.", "error", nil) return } if err != nil { slog.Error("import parse error", "error", err) h.flash(w, r, "import", "Kunne ikke lese filen. Sjekk formatet.", "error", nil) return } imported := 0 for _, ef := range faves { if ef.Description == "" { continue } privacy := ef.Privacy if privacy != "public" && privacy != "private" { privacy = user.DefaultFavePrivacy } fave, err := h.deps.Faves.Create(user.ID, ef.Description, ef.URL, "", ef.Notes, privacy) if err != nil { slog.Error("import: create fave error", "error", err) continue } if len(ef.Tags) > 0 { if err := h.deps.Tags.SetFaveTags(fave.ID, ef.Tags); err != nil { slog.Error("import: set tags error", "error", err) } } imported++ } h.flash(w, r, "import", fmt.Sprintf("Importert %d av %d favoritter.", imported, len(faves)), "success", nil) } func parseImportJSON(r io.Reader) ([]ExportFave, error) { var faves []ExportFave if err := json.NewDecoder(r).Decode(&faves); err != nil { return nil, err } return faves, nil } func parseImportCSV(r io.Reader) ([]ExportFave, error) { cr := csv.NewReader(r) records, err := cr.ReadAll() if err != nil { return nil, err } if len(records) < 2 { return nil, nil } // Find column indices from header row. header := records[0] colMap := make(map[string]int) for i, col := range header { colMap[strings.ToLower(strings.TrimSpace(col))] = i } var faves []ExportFave for _, row := range records[1:] { f := ExportFave{} if idx, ok := colMap["description"]; ok && idx < len(row) { f.Description = row[idx] } if idx, ok := colMap["url"]; ok && idx < len(row) { f.URL = row[idx] } if idx, ok := colMap["notes"]; ok && idx < len(row) { f.Notes = row[idx] } if idx, ok := colMap["privacy"]; ok && idx < len(row) { f.Privacy = row[idx] } if idx, ok := colMap["tags"]; ok && idx < len(row) && row[idx] != "" { f.Tags = strings.Split(row[idx], ",") for i := range f.Tags { f.Tags[i] = strings.TrimSpace(f.Tags[i]) } } if idx, ok := colMap["created_at"]; ok && idx < len(row) { f.CreatedAt = row[idx] } faves = append(faves, f) } return faves, nil }