229 lines
5.9 KiB
Go
229 lines
5.9 KiB
Go
|
|
// 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"
|
||
|
|
)
|
||
|
|
|
||
|
|
// ExportFave is the JSON representation for export/import.
|
||
|
|
type ExportFave struct {
|
||
|
|
Description string `json:"description"`
|
||
|
|
URL string `json:"url,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, 10000, 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,
|
||
|
|
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, 10000, 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", "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.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, "", 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["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
|
||
|
|
}
|