feat: add title columns and migration tracking

This commit is contained in:
2026-04-06 20:00:45 +02:00
parent eb8dbf231a
commit 063a73d43c
11 changed files with 184 additions and 60 deletions

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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 = ?

View File

@@ -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
}

View File

@@ -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

View File

@@ -194,7 +194,7 @@ templ statusOption(anime jikan.Anime, status string, currentStatus string) {
<button
class={ "dropdown-item", templ.KV("active", status == currentStatus) }
hx-post="/api/watchlist"
hx-vals={ fmt.Sprintf(`{"anime_id": "%d", "anime_title": "%s", "anime_image": "%s", "status": "%s"}`, anime.MalID, anime.DisplayTitle(), anime.ImageURL(), status) }
hx-vals={ fmt.Sprintf(`{"anime_id": "%d", "anime_title": "%s", "anime_title_english": "%s", "anime_title_japanese": "%s", "anime_image": "%s", "status": "%s"}`, anime.MalID, anime.Title, anime.TitleEnglish, anime.TitleJapanese, anime.ImageURL(), status) }
hx-target="#watchlist-dropdown"
hx-swap="outerHTML"
>

View File

@@ -700,9 +700,9 @@ func statusOption(anime jikan.Anime, status string, currentStatus string) templ.
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(`{"anime_id": "%d", "anime_title": "%s", "anime_image": "%s", "status": "%s"}`, anime.MalID, anime.DisplayTitle(), anime.ImageURL(), status))
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(`{"anime_id": "%d", "anime_title": "%s", "anime_title_english": "%s", "anime_title_japanese": "%s", "anime_image": "%s", "status": "%s"}`, anime.MalID, anime.Title, anime.TitleEnglish, anime.TitleJapanese, anime.ImageURL(), status))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 197, Col: 164}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 197, Col: 255}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {

View File

@@ -49,12 +49,12 @@ templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentSt
<div class="catalog-item watchlist-item" id={ fmt.Sprintf("watchlist-entry-%d", entry.AnimeID) }>
<a href={ templ.URL(fmt.Sprintf("/anime/%d", entry.AnimeID)) }>
if entry.ImageUrl != "" {
<img src={ entry.ImageUrl } alt={ entry.Title } class="catalog-thumb" loading="lazy" />
<img src={ entry.ImageUrl } alt={ entry.DisplayTitle() } class="catalog-thumb" loading="lazy" />
} else {
<div class="no-image">no image</div>
}
</a>
<div class="catalog-title">{ entry.Title }</div>
<div class="catalog-title">{ entry.DisplayTitle() }</div>
<div class="watchlist-status">{ entry.Status }</div>
<button
class="remove-btn"
@@ -80,12 +80,12 @@ templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentSt
<tr id={ fmt.Sprintf("watchlist-entry-%d", entry.AnimeID) }>
<td>
<a href={ templ.SafeURL(fmt.Sprintf("/anime/%d", entry.AnimeID)) }>
<img src={ entry.ImageUrl } alt={ entry.Title } class="thumb" loading="lazy"/>
<img src={ entry.ImageUrl } alt={ entry.DisplayTitle() } class="thumb" loading="lazy"/>
</a>
</td>
<td class="title-cell">
<a href={ templ.SafeURL(fmt.Sprintf("/anime/%d", entry.AnimeID)) }>
{ entry.Title }
{ entry.DisplayTitle() }
</a>
</td>
<td class="status-cell">{ entry.Status }</td>

View File

@@ -406,9 +406,9 @@ func Watchlist(entries []database.GetUserWatchListRow, layout string, currentSta
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Title)
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(entry.DisplayTitle())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/watchlist.templ`, Line: 52, Col: 54}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/watchlist.templ`, Line: 52, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
@@ -429,9 +429,9 @@ func Watchlist(entries []database.GetUserWatchListRow, layout string, currentSta
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Title)
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(entry.DisplayTitle())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/watchlist.templ`, Line: 57, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/watchlist.templ`, Line: 57, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
@@ -535,9 +535,9 @@ func Watchlist(entries []database.GetUserWatchListRow, layout string, currentSta
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Title)
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(entry.DisplayTitle())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/watchlist.templ`, Line: 83, Col: 55}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/watchlist.templ`, Line: 83, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil {
@@ -561,9 +561,9 @@ func Watchlist(entries []database.GetUserWatchListRow, layout string, currentSta
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Title)
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(entry.DisplayTitle())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/watchlist.templ`, Line: 88, Col: 23}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/watchlist.templ`, Line: 88, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
if templ_7745c5c3_Err != nil {

View File

@@ -0,0 +1,6 @@
-- Add English and Japanese title columns to anime table
ALTER TABLE anime ADD COLUMN title_english TEXT;
ALTER TABLE anime ADD COLUMN title_japanese TEXT;
-- Rename existing title to title_original for clarity
ALTER TABLE anime RENAME COLUMN title TO title_original;