feat: add public watchlist export
This commit is contained in:
@@ -272,6 +272,26 @@ func TestAuthMiddlewareRejectsUnauthenticatedRequests(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthMiddlewareAllowsPublicWatchlistAPI(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
svc := &fakeAuthService{validateErr: errors.New("no auth")}
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(AuthMiddleware(svc))
|
||||||
|
router.GET("/api/public/users/:userID/watchlist", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/public/users/user-42/watchlist", nil)
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if svc.validateSessionCalled || svc.validateAPITokenCalled {
|
||||||
|
t.Fatalf("public watchlist endpoint should not authenticate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type fakeAuthService struct {
|
type fakeAuthService struct {
|
||||||
user *domain.User
|
user *domain.User
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ var publicRoutes = []publicRoute{
|
|||||||
|
|
||||||
// Auth API.
|
// Auth API.
|
||||||
{method: http.MethodPost, path: "/api/auth/login"},
|
{method: http.MethodPost, path: "/api/auth/login"},
|
||||||
|
|
||||||
|
// Explicitly public, read-only API resources.
|
||||||
|
{method: http.MethodGet, path: "/api/public/", prefix: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
func isPublicRequest(method string, path string) bool {
|
func isPublicRequest(method string, path string) bool {
|
||||||
|
|||||||
152
internal/watchlist/export.go
Normal file
152
internal/watchlist/export.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
package watchlist
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mal/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type publicWatchlist struct {
|
||||||
|
SchemaVersion string `json:"schema_version"`
|
||||||
|
GeneratedAt time.Time `json:"generated_at"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Summary watchlistSummary `json:"summary"`
|
||||||
|
Entries []publicWatchlistEntry `json:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type watchlistSummary struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Watching int `json:"watching"`
|
||||||
|
Completed int `json:"completed"`
|
||||||
|
PlanToWatch int `json:"plan_to_watch"`
|
||||||
|
OnHold int `json:"on_hold"`
|
||||||
|
Dropped int `json:"dropped"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type publicWatchlistEntry struct {
|
||||||
|
MALID int64 `json:"mal_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
AddedAt time.Time `json:"added_at"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at"`
|
||||||
|
CompletionDateAccuracy *string `json:"completion_date_accuracy"`
|
||||||
|
LastWatchedAt *time.Time `json:"last_watched_at"`
|
||||||
|
Progress watchlistProgress `json:"progress"`
|
||||||
|
Titles watchlistTitles `json:"titles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type watchlistProgress struct {
|
||||||
|
CurrentEpisode *int64 `json:"current_episode"`
|
||||||
|
CurrentTimeSeconds *float64 `json:"current_time_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type watchlistTitles struct {
|
||||||
|
Preferred string `json:"preferred"`
|
||||||
|
Original string `json:"original"`
|
||||||
|
English *string `json:"english"`
|
||||||
|
Japanese *string `json:"japanese"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPublicWatchlist(userID string, rows []domain.UserWatchListRow, generatedAt time.Time) publicWatchlist {
|
||||||
|
export := publicWatchlist{
|
||||||
|
SchemaVersion: "1.0",
|
||||||
|
GeneratedAt: generatedAt,
|
||||||
|
UserID: userID,
|
||||||
|
Entries: make([]publicWatchlistEntry, 0, len(rows)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
export.Summary.add(row.Status)
|
||||||
|
export.Entries = append(export.Entries, newPublicWatchlistEntry(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
return export
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *watchlistSummary) add(status string) {
|
||||||
|
s.Total++
|
||||||
|
switch status {
|
||||||
|
case "watching":
|
||||||
|
s.Watching++
|
||||||
|
case "completed":
|
||||||
|
s.Completed++
|
||||||
|
case "plan_to_watch":
|
||||||
|
s.PlanToWatch++
|
||||||
|
case "on_hold":
|
||||||
|
s.OnHold++
|
||||||
|
case "dropped":
|
||||||
|
s.Dropped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPublicWatchlistEntry(row domain.UserWatchListRow) publicWatchlistEntry {
|
||||||
|
currentEpisode := nullableInt64(row.CurrentEpisode.Int64, row.CurrentEpisode.Valid)
|
||||||
|
currentTimeSeconds := nullableFloat64(row.CurrentTimeSeconds, row.CurrentEpisode.Valid)
|
||||||
|
lastWatchedAt := nullableTime(row.LastEpisodeAt.Time, row.LastEpisodeAt.Valid)
|
||||||
|
if row.PlaybackCurrentEpisode.Valid {
|
||||||
|
currentEpisode = &row.PlaybackCurrentEpisode.Int64
|
||||||
|
currentTimeSeconds = nullableFloat64(row.PlaybackCurrentTimeSeconds.Float64, row.PlaybackCurrentTimeSeconds.Valid)
|
||||||
|
lastWatchedAt = nullableTime(row.PlaybackUpdatedAt.Time, row.PlaybackUpdatedAt.Valid)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := publicWatchlistEntry{
|
||||||
|
MALID: row.AnimeID,
|
||||||
|
Status: row.Status,
|
||||||
|
AddedAt: row.CreatedAt,
|
||||||
|
LastWatchedAt: lastWatchedAt,
|
||||||
|
Progress: watchlistProgress{
|
||||||
|
CurrentEpisode: currentEpisode,
|
||||||
|
CurrentTimeSeconds: currentTimeSeconds,
|
||||||
|
},
|
||||||
|
Titles: watchlistTitles{
|
||||||
|
Preferred: row.DisplayTitle(),
|
||||||
|
Original: row.TitleOriginal,
|
||||||
|
English: nullableString(row.TitleEnglish.String, row.TitleEnglish.Valid),
|
||||||
|
Japanese: nullableString(row.TitleJapanese.String, row.TitleJapanese.Valid),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if row.Status == "completed" {
|
||||||
|
accuracy := "exact"
|
||||||
|
if row.CompletedAt.Valid {
|
||||||
|
entry.CompletedAt = &row.CompletedAt.Time
|
||||||
|
if row.CompletedAtEstimated {
|
||||||
|
accuracy = "estimated_from_historical_update"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
entry.CompletedAt = &row.UpdatedAt
|
||||||
|
accuracy = "estimated_from_historical_update"
|
||||||
|
}
|
||||||
|
entry.CompletionDateAccuracy = &accuracy
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableString(value string, valid bool) *string {
|
||||||
|
if !valid || strings.TrimSpace(value) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableInt64(value int64, valid bool) *int64 {
|
||||||
|
if !valid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableFloat64(value float64, valid bool) *float64 {
|
||||||
|
if !valid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableTime(value time.Time, valid bool) *time.Time {
|
||||||
|
if !valid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &value
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"mal/internal/server"
|
"mal/internal/server"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -22,6 +24,7 @@ func (h *WatchlistHandler) Register(r *gin.Engine) {
|
|||||||
r.POST("/api/watchlist", h.HandleUpdateWatchlist)
|
r.POST("/api/watchlist", h.HandleUpdateWatchlist)
|
||||||
r.DELETE("/api/watchlist/:id", h.HandleDeleteWatchlist)
|
r.DELETE("/api/watchlist/:id", h.HandleDeleteWatchlist)
|
||||||
r.DELETE("/api/continue-watching/:id", h.HandleDeleteContinueWatching)
|
r.DELETE("/api/continue-watching/:id", h.HandleDeleteContinueWatching)
|
||||||
|
r.GET("/api/public/users/:userID/watchlist", h.HandleGetPublicWatchlist)
|
||||||
r.GET("/watchlist", h.HandleGetWatchlist)
|
r.GET("/watchlist", h.HandleGetWatchlist)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,3 +141,45 @@ func (h *WatchlistHandler) HandleGetWatchlist(c *gin.Context) {
|
|||||||
"User": user,
|
"User": user,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *WatchlistHandler) HandleGetPublicWatchlist(c *gin.Context) {
|
||||||
|
userID := strings.TrimSpace(c.Param("userID"))
|
||||||
|
if userID == "" {
|
||||||
|
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid user id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := h.svc.GetWatchlist(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
server.RespondError(
|
||||||
|
c,
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
"public_watchlist_load_failed",
|
||||||
|
"watchlist",
|
||||||
|
"failed to load public watchlist",
|
||||||
|
map[string]any{"user_id": userID},
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Cache-Control", "no-store")
|
||||||
|
if c.Query("download") == "1" {
|
||||||
|
c.Header("Content-Disposition", `attachment; filename="watchlist-`+safeFilenamePart(userID)+`.json"`)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, newPublicWatchlist(userID, entries, time.Now().UTC()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeFilenamePart(value string) string {
|
||||||
|
const allowed = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"
|
||||||
|
var filename strings.Builder
|
||||||
|
for _, char := range value {
|
||||||
|
if strings.ContainsRune(allowed, char) {
|
||||||
|
filename.WriteRune(char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if filename.Len() == 0 {
|
||||||
|
return "user"
|
||||||
|
}
|
||||||
|
return filename.String()
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ package watchlist
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"mal/internal/db"
|
"mal/internal/db"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
@@ -110,6 +113,196 @@ func TestHandleDeleteContinueWatching(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleGetPublicWatchlistReturnsMinimalJSON(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
addedAt := time.Date(2026, time.June, 1, 12, 30, 0, 0, time.UTC)
|
||||||
|
completedAt := time.Date(2026, time.June, 20, 18, 45, 0, 0, time.UTC)
|
||||||
|
lastWatchedAt := time.Date(2026, time.June, 28, 20, 15, 0, 0, time.UTC)
|
||||||
|
svc := &fakeWatchlistService{watchlist: publicWatchlistRows(addedAt, completedAt, lastWatchedAt)}
|
||||||
|
router := newWatchlistTestRouter(svc)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/public/users/user-42/watchlist", nil)
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/json") {
|
||||||
|
t.Fatalf("Content-Type = %q, want application/json", got)
|
||||||
|
}
|
||||||
|
if svc.watchlistUserID != "user-42" {
|
||||||
|
t.Fatalf("GetWatchlist user id = %q, want user-42", svc.watchlistUserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body publicWatchlistResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("decode response: %v; body=%s", err, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
assertPublicWatchlistEnvelope(t, body)
|
||||||
|
if len(body.Entries) != 2 {
|
||||||
|
t.Fatalf("entries = %d, want 2", len(body.Entries))
|
||||||
|
}
|
||||||
|
assertCompletedPublicEntry(t, body.Entries[0], addedAt, completedAt)
|
||||||
|
assertWatchingPublicEntry(t, body.Entries[1], lastWatchedAt)
|
||||||
|
|
||||||
|
assertMinimalPublicWatchlistJSON(t, rec.Body.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicWatchlistRows(addedAt time.Time, completedAt time.Time, lastWatchedAt time.Time) []domain.UserWatchListRow {
|
||||||
|
return []domain.UserWatchListRow{
|
||||||
|
{
|
||||||
|
AnimeID: 1,
|
||||||
|
Status: "completed",
|
||||||
|
CreatedAt: addedAt,
|
||||||
|
UpdatedAt: completedAt.Add(48 * time.Hour),
|
||||||
|
CompletedAt: sql.NullTime{Time: completedAt, Valid: true},
|
||||||
|
TitleOriginal: "Sousou no Frieren",
|
||||||
|
TitleEnglish: sql.NullString{String: "Frieren: Beyond Journey's End", Valid: true},
|
||||||
|
TitleJapanese: sql.NullString{String: "葬送のフリーレン", Valid: true},
|
||||||
|
ImageUrl: "https://cdn.example/frieren.webp",
|
||||||
|
Airing: sql.NullBool{Bool: false, Valid: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AnimeID: 2,
|
||||||
|
Status: "watching",
|
||||||
|
CreatedAt: addedAt.Add(24 * time.Hour),
|
||||||
|
CurrentEpisode: sql.NullInt64{Int64: 3, Valid: true},
|
||||||
|
CurrentTimeSeconds: 10,
|
||||||
|
PlaybackCurrentEpisode: sql.NullInt64{Int64: 7, Valid: true},
|
||||||
|
PlaybackCurrentTimeSeconds: sql.NullFloat64{Float64: 321.5, Valid: true},
|
||||||
|
PlaybackUpdatedAt: sql.NullTime{Time: lastWatchedAt, Valid: true},
|
||||||
|
TitleOriginal: "Dungeon Meshi",
|
||||||
|
ImageUrl: "https://cdn.example/dungeon.webp",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type publicWatchlistResponse struct {
|
||||||
|
SchemaVersion string `json:"schema_version"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Summary struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Completed int `json:"completed"`
|
||||||
|
Watching int `json:"watching"`
|
||||||
|
} `json:"summary"`
|
||||||
|
Entries []publicWatchlistResponseEntry `json:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type publicWatchlistResponseEntry struct {
|
||||||
|
MALID int64 `json:"mal_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
AddedAt string `json:"added_at"`
|
||||||
|
CompletedAt *string `json:"completed_at"`
|
||||||
|
CompletionDateAccuracy *string `json:"completion_date_accuracy"`
|
||||||
|
LastWatchedAt *string `json:"last_watched_at"`
|
||||||
|
Progress struct {
|
||||||
|
CurrentEpisode *int64 `json:"current_episode"`
|
||||||
|
CurrentTimeSeconds *float64 `json:"current_time_seconds"`
|
||||||
|
} `json:"progress"`
|
||||||
|
Titles struct {
|
||||||
|
Preferred string `json:"preferred"`
|
||||||
|
Original string `json:"original"`
|
||||||
|
English *string `json:"english"`
|
||||||
|
Japanese *string `json:"japanese"`
|
||||||
|
} `json:"titles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPublicWatchlistEnvelope(t *testing.T, body publicWatchlistResponse) {
|
||||||
|
t.Helper()
|
||||||
|
assertEqual(t, "schema version", body.SchemaVersion, "1.0")
|
||||||
|
assertEqual(t, "user id", body.UserID, "user-42")
|
||||||
|
assertEqual(t, "summary total", body.Summary.Total, 2)
|
||||||
|
assertEqual(t, "summary completed", body.Summary.Completed, 1)
|
||||||
|
assertEqual(t, "summary watching", body.Summary.Watching, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertCompletedPublicEntry(t *testing.T, entry publicWatchlistResponseEntry, addedAt time.Time, completedAt time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
assertEqual(t, "completed MAL id", entry.MALID, int64(1))
|
||||||
|
assertEqual(t, "completed status", entry.Status, "completed")
|
||||||
|
assertEqual(t, "added_at", entry.AddedAt, addedAt.Format(time.RFC3339))
|
||||||
|
assertPointerEqual(t, "completed_at", entry.CompletedAt, completedAt.Format(time.RFC3339))
|
||||||
|
assertPointerEqual(t, "completion date accuracy", entry.CompletionDateAccuracy, "exact")
|
||||||
|
assertEqual(t, "preferred title", entry.Titles.Preferred, "Frieren: Beyond Journey's End")
|
||||||
|
assertEqual(t, "original title", entry.Titles.Original, "Sousou no Frieren")
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertWatchingPublicEntry(t *testing.T, entry publicWatchlistResponseEntry, lastWatchedAt time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
assertPointerEqual(t, "watching progress episode", entry.Progress.CurrentEpisode, int64(7))
|
||||||
|
assertPointerEqual(t, "watching progress seconds", entry.Progress.CurrentTimeSeconds, 321.5)
|
||||||
|
assertPointerEqual(t, "last_watched_at", entry.LastWatchedAt, lastWatchedAt.Format(time.RFC3339))
|
||||||
|
assertNilPointer(t, "watching completed_at", entry.CompletedAt)
|
||||||
|
assertNilPointer(t, "watching completion date accuracy", entry.CompletionDateAccuracy)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertEqual[T comparable](t *testing.T, label string, got T, want T) {
|
||||||
|
t.Helper()
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("%s = %v, want %v", label, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPointerEqual[T comparable](t *testing.T, label string, got *T, want T) {
|
||||||
|
t.Helper()
|
||||||
|
if got == nil || *got != want {
|
||||||
|
t.Fatalf("%s = %v, want %v", label, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNilPointer[T any](t *testing.T, label string, got *T) {
|
||||||
|
t.Helper()
|
||||||
|
if got != nil {
|
||||||
|
t.Fatalf("%s = %v, want null", label, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertMinimalPublicWatchlistJSON(t *testing.T, data []byte) {
|
||||||
|
t.Helper()
|
||||||
|
var raw struct {
|
||||||
|
RecommendationGuidance json.RawMessage `json:"recommendation_guidance"`
|
||||||
|
Entries []map[string]json.RawMessage `json:"entries"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
t.Fatalf("decode raw response: %v", err)
|
||||||
|
}
|
||||||
|
if raw.RecommendationGuidance != nil {
|
||||||
|
t.Fatalf("recommendation_guidance should not be part of the public contract")
|
||||||
|
}
|
||||||
|
for _, key := range []string{
|
||||||
|
"updated_at",
|
||||||
|
"status_meaning",
|
||||||
|
"myanimelist_status",
|
||||||
|
"anilist_status",
|
||||||
|
"exclude_from_recommendations",
|
||||||
|
"recommendation_exclusion_reason",
|
||||||
|
"anime",
|
||||||
|
"external_ids",
|
||||||
|
"links",
|
||||||
|
} {
|
||||||
|
if _, exists := raw.Entries[0][key]; exists {
|
||||||
|
t.Fatalf("%s should not be part of the public contract", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetPublicWatchlistCanDownloadJSON(t *testing.T) {
|
||||||
|
router := newWatchlistTestRouter(&fakeWatchlistService{})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/public/users/user-42/watchlist?download=1", nil)
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Content-Disposition"); got != `attachment; filename="watchlist-user-42.json"` {
|
||||||
|
t.Fatalf("Content-Disposition = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newWatchlistTestRouter(svc domain.WatchlistService) *gin.Engine {
|
func newWatchlistTestRouter(svc domain.WatchlistService) *gin.Engine {
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
router.Use(func(c *gin.Context) {
|
router.Use(func(c *gin.Context) {
|
||||||
@@ -134,6 +327,10 @@ type fakeWatchlistService struct {
|
|||||||
|
|
||||||
deletedContinueUserID string
|
deletedContinueUserID string
|
||||||
deletedContinueAnimeID int64
|
deletedContinueAnimeID int64
|
||||||
|
|
||||||
|
watchlist []domain.UserWatchListRow
|
||||||
|
watchlistUserID string
|
||||||
|
watchlistErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *fakeWatchlistService) UpdateEntry(_ context.Context, userID string, animeID int64, status string) error {
|
func (s *fakeWatchlistService) UpdateEntry(_ context.Context, userID string, animeID int64, status string) error {
|
||||||
@@ -149,8 +346,9 @@ func (s *fakeWatchlistService) RemoveEntry(_ context.Context, userID string, ani
|
|||||||
return s.removeErr
|
return s.removeErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *fakeWatchlistService) GetWatchlist(context.Context, string) ([]domain.UserWatchListRow, error) {
|
func (s *fakeWatchlistService) GetWatchlist(_ context.Context, userID string) ([]domain.UserWatchListRow, error) {
|
||||||
return nil, nil
|
s.watchlistUserID = userID
|
||||||
|
return s.watchlist, s.watchlistErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *fakeWatchlistService) GetWatchlistMap(context.Context, string, []int64) (map[int64]bool, error) {
|
func (s *fakeWatchlistService) GetWatchlistMap(context.Context, string, []int64) (map[int64]bool, error) {
|
||||||
|
|||||||
@@ -228,14 +228,6 @@ const toggleWatchlist = async (
|
|||||||
|
|
||||||
type WatchlistSort = "date" | "title";
|
type WatchlistSort = "date" | "title";
|
||||||
|
|
||||||
const csvEscape = (value: unknown): string => {
|
|
||||||
const str = String(value ?? "");
|
|
||||||
if (/[",\r\n]/.test(str)) {
|
|
||||||
return `"${str.replaceAll(/"/g, '""')}"`;
|
|
||||||
}
|
|
||||||
return str;
|
|
||||||
};
|
|
||||||
|
|
||||||
const watchlistItems = (): HTMLElement[] => [
|
const watchlistItems = (): HTMLElement[] => [
|
||||||
...document.querySelectorAll<HTMLElement>(".watchlist-item"),
|
...document.querySelectorAll<HTMLElement>(".watchlist-item"),
|
||||||
];
|
];
|
||||||
@@ -314,35 +306,6 @@ const applyWatchlistFilter = (status: string): void => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportWatchlistCsv = (): void => {
|
|
||||||
const rows = [...watchlistItems()]
|
|
||||||
.toSorted((a, b) => {
|
|
||||||
const dateA = Number.parseInt(a.dataset.updatedAt ?? "0", 10) || 0;
|
|
||||||
const dateB = Number.parseInt(b.dataset.updatedAt ?? "0", 10) || 0;
|
|
||||||
return dateB - dateA;
|
|
||||||
})
|
|
||||||
.map((item) => {
|
|
||||||
const updatedAt = Number.parseInt(item.dataset.updatedAt ?? "0", 10) || 0;
|
|
||||||
const updatedAtISO = updatedAt > 0 ? new Date(updatedAt * 1000).toISOString() : "";
|
|
||||||
const title = item.dataset.title || item.querySelector("h3")?.textContent?.trim() || "";
|
|
||||||
return [item.dataset.malId || "", title, item.dataset.status || "", updatedAtISO];
|
|
||||||
});
|
|
||||||
|
|
||||||
const csv = [["mal_id", "title", "status", "updated_at"], ...rows]
|
|
||||||
.map((row) => row.map(csvEscape).join(","))
|
|
||||||
.join("\r\n");
|
|
||||||
|
|
||||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = url;
|
|
||||||
link.download = "watchlist.csv";
|
|
||||||
document.body.append(link);
|
|
||||||
link.click();
|
|
||||||
link.remove();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
const initWatchlistPage = (): void => {
|
const initWatchlistPage = (): void => {
|
||||||
let currentSortBy: WatchlistSort = "date";
|
let currentSortBy: WatchlistSort = "date";
|
||||||
let sortOrderDesc = true;
|
let sortOrderDesc = true;
|
||||||
@@ -396,12 +359,6 @@ const initWatchlistPage = (): void => {
|
|||||||
sortVisibleWatchlistItems(currentSortBy, sortOrderDesc);
|
sortVisibleWatchlistItems(currentSortBy, sortOrderDesc);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const exportBtn = target.closest<HTMLButtonElement>("button[data-watchlist-export]");
|
|
||||||
if (exportBtn) {
|
|
||||||
exportWatchlistCsv();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,13 +15,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<button type="button" data-unstyled-button data-watchlist-export class="text-foreground-muted transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent" title="Export to CSV" aria-label="Export to CSV">
|
<a href="/api/public/users/{{.User.ID}}/watchlist?download=1" class="text-foreground-muted transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent" title="Export watchlist JSON" aria-label="Export watchlist JSON">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
<path d="M7 10l5 5 5-5"/>
|
<path d="M7 10l5 5 5-5"/>
|
||||||
<path d="M12 15V3"/>
|
<path d="M12 15V3"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</a>
|
||||||
|
|
||||||
<ui-dropdown class="relative block" data-align="right" data-width="min-w-[150px]">
|
<ui-dropdown class="relative block" data-align="right" data-width="min-w-[150px]">
|
||||||
<div data-trigger>
|
<div data-trigger>
|
||||||
|
|||||||
Reference in New Issue
Block a user