favoritter/internal/handler/import_export.go
Ole-Morten Duesund 485d01ce45 feat: add notes field to favorites and enhance OG meta tags
Add an optional long-form "notes" text field to each favorite for
reviews, thoughts, or extended descriptions. The field is stored in
SQLite via a new migration (002_add_fave_notes.sql) and propagated
through the entire stack:

- Model: Notes field on Fave struct
- Store: All SQL queries (Create, GetByID, Update, list methods,
  scanFaves) updated with notes column
- Web handlers: Read/write notes in create, edit, update forms
- API handlers: Notes in create, update, get, import request/response
- Export: Notes included in both JSON and CSV exports
- Import: Notes parsed from both JSON and CSV imports
- Feed: Notes used as Atom feed item summary when present
- Form template: New textarea between URL and image fields
- Detail template: Display notes, enhanced og:description with
  cascade: notes (truncated) → URL → generic fallback text

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:40:08 +02:00

237 lines
6.1 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"
)
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
}