diff --git a/docs/superpowers/plans/2026-05-06-refactor-import-watchlist.md b/docs/superpowers/plans/2026-05-06-refactor-import-watchlist.md new file mode 100644 index 0000000..4621b16 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-refactor-import-watchlist.md @@ -0,0 +1,95 @@ +# Refactor ImportWatchlist Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Refactor `ImportWatchlist` to use a database transaction for the import loop and improve logging. + +**Architecture:** Use `db.BeginTx` to wrap the loop. `ensureAnimeExists` continues to use `s.db` to avoid long-running transactions during external API calls. + +**Tech Stack:** Go, `database/sql`, `csv`. + +--- + +### Task 1: Refactor ImportWatchlist with Transaction + +**Files:** +- Modify: `api/watchlist/service.go` + +- [ ] **Step 1: Update ImportWatchlist implementation** + +```go +func (s *Service) ImportWatchlist(ctx context.Context, userID string, r io.Reader) error { + txQueries, tx, err := db.BeginTx(ctx, s.sqlDB) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + reader := csv.NewReader(r) + if _, err := reader.Read(); err != nil { + return fmt.Errorf("failed to read csv header: %w", err) + } + + records, err := reader.ReadAll() + if err != nil { + return fmt.Errorf("failed to read csv records: %w", err) + } + + for i, record := range records { + if len(record) < 4 { + log.Printf("skipping row %d: insufficient columns", i+2) // i+2 because i is 0-indexed record after header + continue + } + + animeID, err := strconv.ParseInt(record[0], 10, 64) + if err != nil { + return fmt.Errorf("row %d: invalid anime id: %w", i+2, err) + } + + status := record[1] + if _, ok := validStatuses[status]; !ok { + status = "plan_to_watch" + } + + currentEpisode, _ := strconv.ParseInt(record[2], 10, 64) + currentTimeSeconds, _ := strconv.ParseFloat(record[3], 64) + + if err := s.ensureAnimeExists(ctx, animeID); err != nil { + return fmt.Errorf("row %d: failed to ensure anime: %w", i+2, err) + } + + _, err = txQueries.UpsertWatchListEntry(ctx, db.UpsertWatchListEntryParams{ + ID: uuid.New().String(), + UserID: userID, + AnimeID: animeID, + Status: status, + CurrentEpisode: sql.NullInt64{Int64: currentEpisode, Valid: currentEpisode > 0}, + CurrentTimeSeconds: currentTimeSeconds, + }) + if err != nil { + return fmt.Errorf("row %d: failed to upsert entry: %w", i+2, err) + } + } + + return tx.Commit() +} +``` + +- [ ] **Step 2: Add missing import `log`** + +- [ ] **Step 3: Verify compilation** + +Run: `go build ./api/watchlist/...` +Expected: Success (no output) + +- [ ] **Step 4: Verify tests** + +Run: `go test ./api/watchlist/...` +Expected: Success + +- [ ] **Step 5: Commit** + +```bash +git add api/watchlist/service.go +git commit -m "refactor: wrap watchlist import in transaction" +``` diff --git a/docs/superpowers/plans/2026-05-06-watchlist-export-import.md b/docs/superpowers/plans/2026-05-06-watchlist-export-import.md new file mode 100644 index 0000000..fcb1e25 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-watchlist-export-import.md @@ -0,0 +1,219 @@ +# Watchlist CSV Export and Import Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add CSV export and import functionality to the anime watchlist to allow users to backup and restore their lists. + +**Architecture:** Client-side CSV generation for export using JavaScript Blobs. Server-side CSV parsing for import using a new API endpoint and Go's `encoding/csv` package. + +**Tech Stack:** Go (Backend), HTML/JavaScript (Frontend), Tailwind CSS (Styling). + +--- + +### Task 1: Add Import Logic to Watchlist Service + +**Files:** +- Modify: `api/watchlist/service.go` + +- [ ] **Step 1: Add ImportWatchlist method** +Add a method to handle CSV parsing and entry upsertion. + +```go +func (s *Service) ImportWatchlist(ctx context.Context, userID string, r io.Reader) error { + reader := csv.NewReader(r) + // Read header + if _, err := reader.Read(); err != nil { + return fmt.Errorf("failed to read csv header: %w", err) + } + + records, err := reader.ReadAll() + if err != nil { + return fmt.Errorf("failed to read csv records: %w", err) + } + + for i, record := range records { + if len(record) < 4 { + continue // Skip malformed rows + } + + animeID, err := strconv.ParseInt(record[0], 10, 64) + if err != nil { + return fmt.Errorf("row %d: invalid anime id: %w", i+1, err) + } + + status := record[1] + if _, ok := validStatuses[status]; !ok { + status = "plan_to_watch" + } + + currentEpisode, _ := strconv.ParseInt(record[2], 10, 64) + currentTimeSeconds, _ := strconv.ParseFloat(record[3], 64) + + if err := s.ensureAnimeExists(ctx, animeID); err != nil { + return fmt.Errorf("row %d: failed to ensure anime: %w", i+1, err) + } + + _, err = s.db.UpsertWatchListEntry(ctx, db.UpsertWatchListEntryParams{ + ID: uuid.New().String(), + UserID: userID, + AnimeID: animeID, + Status: status, + CurrentEpisode: sql.NullInt64{Int64: currentEpisode, Valid: currentEpisode > 0}, + CurrentTimeSeconds: currentTimeSeconds, + }) + if err != nil { + return fmt.Errorf("row %d: failed to upsert entry: %w", i+1, err) + } + } + + return nil +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add api/watchlist/service.go +git commit -m "feat: add ImportWatchlist to service" +``` + +### Task 2: Create Import API Endpoint + +**Files:** +- Modify: `api/watchlist/handler.go` +- Modify: `internal/server/routes.go` + +- [ ] **Step 1: Add HandleImportWatchlist to Handler** + +```go +func (h *Handler) HandleImportWatchlist(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + user := middleware.GetUser(r.Context()) + if user == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + file, _, err := r.FormFile("file") + if err != nil { + http.Error(w, "failed to get file from request", http.StatusBadRequest) + return + } + defer file.Close() + + if err := h.service.ImportWatchlist(r.Context(), user.ID, file); err != nil { + log.Printf("import failed: %v", err) + http.Error(w, "import failed: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("HX-Redirect", "/watchlist") + w.WriteHeader(http.StatusOK) +} +``` + +- [ ] **Step 2: Register route in internal/server/routes.go** + +```go +// Find existing watchlist routes and add: +mux.HandleFunc("/api/watchlist/import", watchlistHandler.HandleImportWatchlist) +``` + +- [ ] **Step 3: Commit** + +```bash +git add api/watchlist/handler.go internal/server/routes.go +git commit -m "feat: add watchlist import api endpoint" +``` + +### Task 3: Update Frontend Templates with Export/Import UI + +**Files:** +- Modify: `templates/watchlist.gohtml` +- Modify: `templates/watchlist_partial.gohtml` + +- [ ] **Step 1: Add buttons and JS logic to templates** +Add the UI components and scripts for CSV generation and file upload. + +```html + +