diff --git a/cmd/server/main.go b/cmd/server/main.go index 9a3cdc7..a78ad58 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -17,6 +17,56 @@ import ( "malago/internal/templates" ) +func runMigrations(db *sql.DB) error { + // Create migration tracking table + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS migration_version ( + name TEXT PRIMARY KEY, + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `) + if err != nil { + return err + } + + migrations := []string{ + "migrations/001_init.sql", + "migrations/002_add_anime_titles.sql", + } + + for _, migrationFile := range migrations { + // Check if migration already applied + var exists int + err := db.QueryRow("SELECT COUNT(*) FROM migration_version WHERE name = ?", migrationFile).Scan(&exists) + if err != nil { + return err + } + if exists > 0 { + log.Printf("migration %s already applied, skipping", migrationFile) + continue + } + + // Read and execute migration + migrationSQL, err := os.ReadFile(migrationFile) + if err != nil { + return err + } + if _, err := db.Exec(string(migrationSQL)); err != nil { + return err + } + + // Mark as applied + _, err = db.Exec("INSERT INTO migration_version (name) VALUES (?)", migrationFile) + if err != nil { + return err + } + + log.Printf("migration %s applied successfully", migrationFile) + } + + return nil +} + func main() { dbFile := os.Getenv("DATABASE_FILE") if dbFile == "" { @@ -29,12 +79,8 @@ func main() { } defer db.Close() - // Run migrations (assuming local dev setup, simplistic execution) - migrationSQL, err := os.ReadFile("migrations/001_init.sql") - if err != nil { - log.Fatalf("failed to read migrations: %v", err) - } - if _, err := db.Exec(string(migrationSQL)); err != nil { + // Run migrations with tracking + if err := runMigrations(db); err != nil { log.Fatalf("failed to run migrations: %v", err) } diff --git a/internal/database/helpers.go b/internal/database/helpers.go new file mode 100644 index 0000000..0cc45c4 --- /dev/null +++ b/internal/database/helpers.go @@ -0,0 +1,12 @@ +package database + +// DisplayTitle returns the English title if available, otherwise Japanese, otherwise original +func (r GetUserWatchListRow) DisplayTitle() string { + if r.TitleEnglish.Valid && r.TitleEnglish.String != "" { + return r.TitleEnglish.String + } + if r.TitleJapanese.Valid && r.TitleJapanese.String != "" { + return r.TitleJapanese.String + } + return r.TitleOriginal +} diff --git a/internal/database/models.go b/internal/database/models.go index 4134ac1..9bd84f1 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -5,6 +5,7 @@ package database import ( + "database/sql" "time" ) @@ -17,10 +18,12 @@ type Account struct { } type Anime struct { - ID int64 `json:"id"` - Title string `json:"title"` - ImageUrl string `json:"image_url"` - CreatedAt time.Time `json:"created_at"` + ID int64 `json:"id"` + TitleOriginal string `json:"title_original"` + ImageUrl string `json:"image_url"` + CreatedAt time.Time `json:"created_at"` + TitleEnglish sql.NullString `json:"title_english"` + TitleJapanese sql.NullString `json:"title_japanese"` } type Session struct { diff --git a/internal/database/queries.sql b/internal/database/queries.sql index 630dee1..c7e1339 100644 --- a/internal/database/queries.sql +++ b/internal/database/queries.sql @@ -24,10 +24,12 @@ DELETE FROM session WHERE id = ?; DELETE FROM session WHERE user_id = ?; -- name: UpsertAnime :one -INSERT INTO anime (id, title, image_url) -VALUES (?, ?, ?) +INSERT INTO anime (id, title_original, title_english, title_japanese, image_url) +VALUES (?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET - title = excluded.title, + title_original = excluded.title_original, + title_english = excluded.title_english, + title_japanese = excluded.title_japanese, image_url = excluded.image_url RETURNING *; @@ -47,7 +49,12 @@ SELECT * FROM watch_list_entry WHERE user_id = ? AND anime_id = ? LIMIT 1; -- name: GetUserWatchList :many -SELECT e.*, a.title, a.image_url +SELECT + e.*, + a.title_original, + a.title_english, + a.title_japanese, + a.image_url FROM watch_list_entry e JOIN anime a ON e.anime_id = a.id WHERE e.user_id = ? diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index fd20721..d17618f 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -7,6 +7,7 @@ package database import ( "context" + "database/sql" "time" ) @@ -92,7 +93,7 @@ func (q *Queries) DeleteWatchListEntry(ctx context.Context, arg DeleteWatchListE } const getAnime = `-- name: GetAnime :one -SELECT id, title, image_url, created_at FROM anime WHERE id = ? LIMIT 1 +SELECT id, title_original, image_url, created_at, title_english, title_japanese FROM anime WHERE id = ? LIMIT 1 ` func (q *Queries) GetAnime(ctx context.Context, id int64) (Anime, error) { @@ -100,9 +101,11 @@ func (q *Queries) GetAnime(ctx context.Context, id int64) (Anime, error) { var i Anime err := row.Scan( &i.ID, - &i.Title, + &i.TitleOriginal, &i.ImageUrl, &i.CreatedAt, + &i.TitleEnglish, + &i.TitleJapanese, ) return i, err } @@ -156,7 +159,12 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, } const getUserWatchList = `-- name: GetUserWatchList :many -SELECT e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, a.title, a.image_url +SELECT + e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, + a.title_original, + a.title_english, + a.title_japanese, + a.image_url FROM watch_list_entry e JOIN anime a ON e.anime_id = a.id WHERE e.user_id = ? @@ -164,14 +172,16 @@ ORDER BY e.updated_at DESC ` type GetUserWatchListRow struct { - ID string `json:"id"` - UserID string `json:"user_id"` - AnimeID int64 `json:"anime_id"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Title string `json:"title"` - ImageUrl string `json:"image_url"` + ID string `json:"id"` + UserID string `json:"user_id"` + AnimeID int64 `json:"anime_id"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + TitleOriginal string `json:"title_original"` + TitleEnglish sql.NullString `json:"title_english"` + TitleJapanese sql.NullString `json:"title_japanese"` + ImageUrl string `json:"image_url"` } func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error) { @@ -190,7 +200,9 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse &i.Status, &i.CreatedAt, &i.UpdatedAt, - &i.Title, + &i.TitleOriginal, + &i.TitleEnglish, + &i.TitleJapanese, &i.ImageUrl, ); err != nil { return nil, err @@ -231,28 +243,40 @@ func (q *Queries) GetWatchListEntry(ctx context.Context, arg GetWatchListEntryPa } const upsertAnime = `-- name: UpsertAnime :one -INSERT INTO anime (id, title, image_url) -VALUES (?, ?, ?) +INSERT INTO anime (id, title_original, title_english, title_japanese, image_url) +VALUES (?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET - title = excluded.title, + title_original = excluded.title_original, + title_english = excluded.title_english, + title_japanese = excluded.title_japanese, image_url = excluded.image_url -RETURNING id, title, image_url, created_at +RETURNING id, title_original, image_url, created_at, title_english, title_japanese ` type UpsertAnimeParams struct { - ID int64 `json:"id"` - Title string `json:"title"` - ImageUrl string `json:"image_url"` + ID int64 `json:"id"` + TitleOriginal string `json:"title_original"` + TitleEnglish sql.NullString `json:"title_english"` + TitleJapanese sql.NullString `json:"title_japanese"` + ImageUrl string `json:"image_url"` } func (q *Queries) UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime, error) { - row := q.db.QueryRowContext(ctx, upsertAnime, arg.ID, arg.Title, arg.ImageUrl) + row := q.db.QueryRowContext(ctx, upsertAnime, + arg.ID, + arg.TitleOriginal, + arg.TitleEnglish, + arg.TitleJapanese, + arg.ImageUrl, + ) var i Anime err := row.Scan( &i.ID, - &i.Title, + &i.TitleOriginal, &i.ImageUrl, &i.CreatedAt, + &i.TitleEnglish, + &i.TitleJapanese, ) return i, err } diff --git a/internal/handlers/watchlist.go b/internal/handlers/watchlist.go index cd1e56d..9878093 100644 --- a/internal/handlers/watchlist.go +++ b/internal/handlers/watchlist.go @@ -1,6 +1,7 @@ package handlers import ( + "database/sql" "encoding/json" "fmt" "net/http" @@ -42,6 +43,8 @@ func (h *WatchlistHandler) HandleUpdateWatchlist(w http.ResponseWriter, r *http. animeIDStr := r.FormValue("anime_id") animeTitle := r.FormValue("anime_title") + animeTitleEnglish := r.FormValue("anime_title_english") + animeTitleJapanese := r.FormValue("anime_title_japanese") animeImage := r.FormValue("anime_image") status := r.FormValue("status") @@ -53,9 +56,11 @@ func (h *WatchlistHandler) HandleUpdateWatchlist(w http.ResponseWriter, r *http. // Ensure the anime exists in our local DB first (foreign key constraint) _, err = h.db.UpsertAnime(r.Context(), database.UpsertAnimeParams{ - ID: animeID, - Title: animeTitle, - ImageUrl: animeImage, + ID: animeID, + TitleOriginal: animeTitle, + TitleEnglish: sql.NullString{String: animeTitleEnglish, Valid: animeTitleEnglish != ""}, + TitleJapanese: sql.NullString{String: animeTitleJapanese, Valid: animeTitleJapanese != ""}, + ImageUrl: animeImage, }) if err != nil { http.Error(w, fmt.Sprintf("failed to save anime reference: %v", err), http.StatusInternalServerError) @@ -75,7 +80,16 @@ func (h *WatchlistHandler) HandleUpdateWatchlist(w http.ResponseWriter, r *http. return } - templates.WatchlistDropdown(int(animeID), animeTitle, animeImage, status).Render(r.Context(), w) + // Determine display title (prefer English) + displayTitle := animeTitleEnglish + if displayTitle == "" { + displayTitle = animeTitleJapanese + } + if displayTitle == "" { + displayTitle = animeTitle + } + + templates.WatchlistDropdown(int(animeID), displayTitle, animeImage, status).Render(r.Context(), w) } func (h *WatchlistHandler) HandleDeleteWatchlist(w http.ResponseWriter, r *http.Request) { @@ -121,8 +135,18 @@ func (h *WatchlistHandler) HandleDeleteWatchlist(w http.ResponseWriter, r *http. return } + // Determine display title for dropdown (prefer English) + displayTitle := "" + if anime.TitleEnglish.Valid && anime.TitleEnglish.String != "" { + displayTitle = anime.TitleEnglish.String + } else if anime.TitleJapanese.Valid && anime.TitleJapanese.String != "" { + displayTitle = anime.TitleJapanese.String + } else { + displayTitle = anime.TitleOriginal + } + // Otherwise return updated dropdown for anime page - templates.WatchlistDropdown(int(animeID), anime.Title, anime.ImageUrl, "").Render(r.Context(), w) + templates.WatchlistDropdown(int(animeID), displayTitle, anime.ImageUrl, "").Render(r.Context(), w) } func (h *WatchlistHandler) HandleGetWatchlist(w http.ResponseWriter, r *http.Request) { @@ -206,7 +230,7 @@ func (h *WatchlistHandler) HandleExportWatchlist(w http.ResponseWriter, r *http. for i, entry := range entries { export.Entries[i] = WatchlistExportEntry{ AnimeID: entry.AnimeID, - Title: entry.Title, + Title: entry.DisplayTitle(), ImageURL: entry.ImageUrl, Status: entry.Status, UpdatedAt: entry.UpdatedAt.Format(time.RFC3339), @@ -251,11 +275,13 @@ func (h *WatchlistHandler) HandleImportWatchlist(w http.ResponseWriter, r *http. imported := 0 for _, entry := range export.Entries { - // Upsert anime + // Upsert anime - store title as original (we don't know which type it is from export) _, err := h.db.UpsertAnime(r.Context(), database.UpsertAnimeParams{ - ID: entry.AnimeID, - Title: entry.Title, - ImageUrl: entry.ImageURL, + ID: entry.AnimeID, + TitleOriginal: entry.Title, + TitleEnglish: sql.NullString{}, + TitleJapanese: sql.NullString{}, + ImageUrl: entry.ImageURL, }) if err != nil { continue diff --git a/internal/templates/anime.templ b/internal/templates/anime.templ index e7953d8..bfba3ef 100644 --- a/internal/templates/anime.templ +++ b/internal/templates/anime.templ @@ -194,7 +194,7 @@ templ statusOption(anime jikan.Anime, status string, currentStatus string) {