package utils import ( "strconv" "strings" "bookhoard/internal/database" "github.com/jackc/pgx/v5/pgtype" ) // Per-field user-override tracking for media item metadata. // // metadata_overrides is a TEXT[] column on media_items holding the names of // columns the user has customized via the metadata editor. Library scans and // per-book rescans MUST preserve those columns (applyMetadataOverrides); // only the reset-to-scanned-defaults action clears the set. const ( OverrideTitle = "title" OverrideAuthor = "author" OverrideISBN = "isbn" OverrideDescription = "description" OverrideCoverImagePath = "cover_image_path" OverrideSeries = "series" OverrideSeriesNumber = "series_number" OverrideTags = "tags" OverrideAsin = "asin" OverrideDatePublished = "date_published" OverridePublisher = "publisher" OverrideContributors = "contributors" OverrideLanguage = "language" OverrideEdition = "edition" OverridePageCount = "page_count" OverrideGenre = "genre" OverrideCopyrightYear = "copyright_year" OverrideGoodreadsID = "goodreads_id" OverrideOpenlibraryID = "openlibrary_id" OverrideGoogleBooksID = "google_books_id" OverrideMangaType = "manga_type" OverrideReadingDirection = "reading_direction" OverrideSeriesCount = "series_count" OverrideVolume = "volume" OverrideImprint = "imprint" OverrideAgeRating = "age_rating" OverrideWebURL = "web_url" OverrideMetadataNotes = "metadata_notes" OverrideCommunityRating = "community_rating" OverrideStoryArc = "story_arc" OverrideIsBlackAndWhite = "is_black_and_white" OverrideAlternateInfo = "alternate_info" OverrideScanInformation = "scan_information" OverrideSummary = "summary" ) // hasOverride reports whether key is in the override set. func hasOverride(overrides []string, key string) bool { for _, k := range overrides { if k == key { return true } } return false } // MergeOverrides unions existing and added, preserving order and dropping // duplicates. Returns a non-nil slice so it always satisfies NOT NULL columns. func MergeOverrides(existing []string, added ...string) []string { seen := make(map[string]bool, len(existing)+len(added)) merged := make([]string, 0, len(existing)+len(added)) for _, k := range existing { if k != "" && !seen[k] { seen[k] = true merged = append(merged, k) } } for _, k := range added { if k != "" && !seen[k] { seen[k] = true merged = append(merged, k) } } return merged } // Normalizers turn nullable column values into comparable strings so that // "unset" (invalid/zero) forms compare equal regardless of which side they // come from. func normText(t pgtype.Text) string { if !t.Valid { return "" } return t.String } func normInt(i pgtype.Int4) string { if !i.Valid { return "" } return strconv.FormatInt(int64(i.Int32), 10) } func normFloat(f pgtype.Float8) string { if !f.Valid { return "" } return strconv.FormatFloat(f.Float64, 'g', -1, 64) } func normBool(b pgtype.Bool) string { if !b.Valid { return "" } return strconv.FormatBool(b.Bool) } func normDate(d pgtype.Date) string { if !d.Valid { return "" } return d.Time.Format("2006-01-02") } func normStringSlice(s []string) string { if len(s) == 0 { return "" } return strings.Join(s, "\x1f") } func normBytes(b []byte) string { if len(b) == 0 { return "" } return string(b) } // detectMetadataOverridesDiff returns the keys whose value in params differs // from the existing row. Used by the metadata editor save path to grow the // override set with exactly the fields the user changed. func detectMetadataOverridesDiff(params database.UpdateMediaItemParams, existing database.MediaItems) []string { var changed []string add := func(key string, differs bool) { if differs { changed = append(changed, key) } } add(OverrideTitle, params.Title != existing.Title) add(OverrideAuthor, normText(params.Author) != normText(existing.Author)) add(OverrideISBN, normText(params.Isbn) != normText(existing.Isbn)) add(OverrideDescription, normText(params.Description) != normText(existing.Description)) add(OverrideSeries, normText(params.Series) != normText(existing.Series)) add(OverrideSeriesNumber, normInt(params.SeriesNumber) != normInt(existing.SeriesNumber)) add(OverrideTags, normStringSlice(params.Tags) != normStringSlice(existing.Tags)) add(OverrideAsin, normText(params.Asin) != normText(existing.Asin)) add(OverrideDatePublished, normDate(params.DatePublished) != normDate(existing.DatePublished)) add(OverridePublisher, normText(params.Publisher) != normText(existing.Publisher)) add(OverrideContributors, normStringSlice(params.Contributors) != normStringSlice(existing.Contributors)) add(OverrideLanguage, normText(params.Language) != normText(existing.Language)) add(OverrideEdition, normText(params.Edition) != normText(existing.Edition)) add(OverridePageCount, normInt(params.PageCount) != normInt(existing.PageCount)) add(OverrideGenre, normText(params.Genre) != normText(existing.Genre)) add(OverrideCopyrightYear, normInt(params.CopyrightYear) != normInt(existing.CopyrightYear)) add(OverrideGoodreadsID, normText(params.GoodreadsID) != normText(existing.GoodreadsID)) add(OverrideOpenlibraryID, normText(params.OpenlibraryID) != normText(existing.OpenlibraryID)) add(OverrideGoogleBooksID, normText(params.GoogleBooksID) != normText(existing.GoogleBooksID)) add(OverrideMangaType, normText(params.MangaType) != normText(existing.MangaType)) add(OverrideReadingDirection, normText(params.ReadingDirection) != normText(existing.ReadingDirection)) add(OverrideSeriesCount, normInt(params.SeriesCount) != normInt(existing.SeriesCount)) add(OverrideVolume, normInt(params.Volume) != normInt(existing.Volume)) add(OverrideImprint, normText(params.Imprint) != normText(existing.Imprint)) add(OverrideAgeRating, normText(params.AgeRating) != normText(existing.AgeRating)) add(OverrideWebURL, normText(params.WebUrl) != normText(existing.WebUrl)) add(OverrideMetadataNotes, normText(params.MetadataNotes) != normText(existing.MetadataNotes)) add(OverrideCommunityRating, normFloat(params.CommunityRating) != normFloat(existing.CommunityRating)) add(OverrideStoryArc, normText(params.StoryArc) != normText(existing.StoryArc)) add(OverrideIsBlackAndWhite, normBool(params.IsBlackAndWhite) != normBool(existing.IsBlackAndWhite)) add(OverrideAlternateInfo, normBytes(params.AlternateInfo) != normBytes(existing.AlternateInfo)) add(OverrideScanInformation, normText(params.ScanInformation) != normText(existing.ScanInformation)) add(OverrideSummary, normText(params.Summary) != normText(existing.Summary)) return changed } // DetectMetadataOverrides returns the union of the existing override set and // any fields whose incoming (user-submitted) values differ from the stored // row. Overrides accumulate: a field stays protected until an explicit reset, // even if a later save reverts the value. func DetectMetadataOverrides(params database.UpdateMediaItemParams, existing database.MediaItems) []string { return MergeOverrides(existing.MetadataOverrides, detectMetadataOverridesDiff(params, existing)...) } // ApplyMetadataOverrides restores every overridden field in params from the // existing row so scanner updates cannot clobber user customizations. Derived // search columns are restored together with their base column. func ApplyMetadataOverrides(params *database.UpdateMediaItemParams, existing database.MediaItems) { o := existing.MetadataOverrides if hasOverride(o, OverrideTitle) { params.Title = existing.Title } if hasOverride(o, OverrideAuthor) { params.Author = existing.Author } if hasOverride(o, OverrideISBN) { params.Isbn = existing.Isbn } if hasOverride(o, OverrideDescription) { params.Description = existing.Description } if hasOverride(o, OverrideCoverImagePath) { params.CoverImagePath = existing.CoverImagePath } if hasOverride(o, OverrideSeries) { params.Series = existing.Series } if hasOverride(o, OverrideSeriesNumber) { params.SeriesNumber = existing.SeriesNumber } if hasOverride(o, OverrideTags) { params.Tags = existing.Tags params.TagsSearch = existing.TagsSearch } if hasOverride(o, OverrideAsin) { params.Asin = existing.Asin } if hasOverride(o, OverrideDatePublished) { params.DatePublished = existing.DatePublished } if hasOverride(o, OverridePublisher) { params.Publisher = existing.Publisher } if hasOverride(o, OverrideContributors) { params.Contributors = existing.Contributors params.ContributorsSearch = existing.ContributorsSearch } if hasOverride(o, OverrideLanguage) { params.Language = existing.Language } if hasOverride(o, OverrideEdition) { params.Edition = existing.Edition } if hasOverride(o, OverridePageCount) { params.PageCount = existing.PageCount } if hasOverride(o, OverrideGenre) { params.Genre = existing.Genre } if hasOverride(o, OverrideCopyrightYear) { params.CopyrightYear = existing.CopyrightYear } if hasOverride(o, OverrideGoodreadsID) { params.GoodreadsID = existing.GoodreadsID } if hasOverride(o, OverrideOpenlibraryID) { params.OpenlibraryID = existing.OpenlibraryID } if hasOverride(o, OverrideGoogleBooksID) { params.GoogleBooksID = existing.GoogleBooksID } if hasOverride(o, OverrideMangaType) { params.MangaType = existing.MangaType } if hasOverride(o, OverrideReadingDirection) { params.ReadingDirection = existing.ReadingDirection } if hasOverride(o, OverrideSeriesCount) { params.SeriesCount = existing.SeriesCount } if hasOverride(o, OverrideVolume) { params.Volume = existing.Volume } if hasOverride(o, OverrideImprint) { params.Imprint = existing.Imprint } if hasOverride(o, OverrideAgeRating) { params.AgeRating = existing.AgeRating } if hasOverride(o, OverrideWebURL) { params.WebUrl = existing.WebUrl } if hasOverride(o, OverrideMetadataNotes) { params.MetadataNotes = existing.MetadataNotes } if hasOverride(o, OverrideCommunityRating) { params.CommunityRating = existing.CommunityRating } if hasOverride(o, OverrideStoryArc) { params.StoryArc = existing.StoryArc } if hasOverride(o, OverrideIsBlackAndWhite) { params.IsBlackAndWhite = existing.IsBlackAndWhite } if hasOverride(o, OverrideAlternateInfo) { params.AlternateInfo = existing.AlternateInfo } if hasOverride(o, OverrideScanInformation) { params.ScanInformation = existing.ScanInformation } if hasOverride(o, OverrideSummary) { params.Summary = existing.Summary } }