refactor(utils): one PgTextString for the four pgtype.Text unwrappers

handlers textToString, router textToString, router getText, and utils
normText were four copies of the same NULL-to-empty unwrap (99 call
sites). One exported utils.PgTextString replaces them all; the private
normText (comparison normalizer) stays but is now the odd one out.

Harness fix found on the way: setupTestServer built a full wiring
literal for route registration and then RETURNED a second, sparser
literal that never copied AnnotationService — setup.AnnotationService
was always nil on the struct every test receives. Nobody noticed until
cross_library_move_test became the first caller (it previously used
the now-retired plain CreateMediaHighlight query); it now exercises
the real SaveHighlight path through the wired service.
This commit is contained in:
John O'Keefe committed 2026-10-17 16:36:58 -04:00
1 parent 00305ffc7b
commit 188ae076ab
15 files changed
+126 -125

No files matched your search

+9 -4
View File
@@ -12,6 +12,7 @@ import (
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/services" "bookhoard/internal/services"
wsync "bookhoard/internal/sync"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
@@ -160,11 +161,13 @@ func TestCrossLibraryMovePreservesHistory(t *testing.T) {
TotalPages: pgtype.Int4{Int32: 300, Valid: true}, TotalPages: pgtype.Int4{Int32: 300, Valid: true},
}) })
require.NoError(t, err) require.NoError(t, err)
_, err = setup.DB.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{ _, err = setup.AnnotationService.SaveHighlight(ctx, wsync.SaveHighlightRequest{
MediaItemID: original.ID, MediaItemID: original.ID,
UserID: adminPgID, UserID: adminPgID,
SelectionText: "a line worth keeping", SelectionText: "a line worth keeping",
Color: pgtype.Text{String: "#ffff00", Valid: true}, Color: "#ffff00",
Source: "web",
ModifiedAt: time.Now(),
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -419,11 +422,13 @@ func archiveAndIsolate(t *testing.T, setup *TestServerSetup, adminID, adminPgID
TotalPages: pgtype.Int4{Int32: 200, Valid: true}, TotalPages: pgtype.Int4{Int32: 200, Valid: true},
}) })
require.NoError(t, err) require.NoError(t, err)
_, err = setup.DB.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{ _, err = setup.AnnotationService.SaveHighlight(ctx, wsync.SaveHighlightRequest{
MediaItemID: item.ID, MediaItemID: item.ID,
UserID: adminPgID, UserID: adminPgID,
SelectionText: "annotation on the old copy", SelectionText: "annotation on the old copy",
Color: pgtype.Text{String: "#00ff00", Valid: true}, Color: "#00ff00",
Source: "web",
ModifiedAt: time.Now(),
}) })
require.NoError(t, err) require.NoError(t, err)
+1
View File
@@ -717,6 +717,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
ConnManager: connManager, ConnManager: connManager,
QueueProcessor: queueProcessor, QueueProcessor: queueProcessor,
ProgressService: progressService, ProgressService: progressService,
AnnotationService: annotationService,
CleanupCancel: cleanupCancel, CleanupCancel: cleanupCancel,
QueueCtx: queueCtx, QueueCtx: queueCtx,
QueueCancel: queueCancel, QueueCancel: queueCancel,
+4 -4
View File
@@ -114,7 +114,7 @@ func BookCardsFromMediaItems(rows []database.MediaItems, libTypes map[string]str
cards = append(cards, buildBookCard(cardSource{ cards = append(cards, buildBookCard(cardSource{
ID: uuid.UUID(r.ID.Bytes), ID: uuid.UUID(r.ID.Bytes),
Title: r.Title, Title: r.Title,
Author: textToString(r.Author), Author: utils.PgTextString(r.Author),
LibraryID: r.LibraryID, LibraryID: r.LibraryID,
CoverPath: r.CoverImagePath, CoverPath: r.CoverImagePath,
FormatGroup: r.FormatGroup, FormatGroup: r.FormatGroup,
@@ -134,7 +134,7 @@ func BookCardsFromCollectionDashRows(rows []database.GetCollectionItemsForDashbo
cards = append(cards, buildBookCard(cardSource{ cards = append(cards, buildBookCard(cardSource{
ID: uuid.UUID(r.ID.Bytes), ID: uuid.UUID(r.ID.Bytes),
Title: r.Title, Title: r.Title,
Author: textToString(r.Author), Author: utils.PgTextString(r.Author),
LibraryID: r.LibraryID, LibraryID: r.LibraryID,
CoverPath: r.CoverImagePath, CoverPath: r.CoverImagePath,
FormatGroup: r.FormatGroup, FormatGroup: r.FormatGroup,
@@ -154,7 +154,7 @@ func BookCardsFromCollectionItemRows(rows []database.GetCollectionItemsRow, libT
cards = append(cards, buildBookCard(cardSource{ cards = append(cards, buildBookCard(cardSource{
ID: uuid.UUID(r.MediaItemID.Bytes), ID: uuid.UUID(r.MediaItemID.Bytes),
Title: r.Title, Title: r.Title,
Author: textToString(r.Author), Author: utils.PgTextString(r.Author),
LibraryID: r.LibraryID, LibraryID: r.LibraryID,
CoverPath: r.CoverImagePath, CoverPath: r.CoverImagePath,
FormatGroup: r.FormatGroup, FormatGroup: r.FormatGroup,
@@ -173,7 +173,7 @@ func BookCardsFromUnified(rows []database.SearchMediaItemsUnifiedRow) []BookInfo
cards = append(cards, buildBookCard(cardSource{ cards = append(cards, buildBookCard(cardSource{
ID: uuid.UUID(r.ID.Bytes), ID: uuid.UUID(r.ID.Bytes),
Title: r.Title, Title: r.Title,
Author: textToString(r.Author), Author: utils.PgTextString(r.Author),
LibraryID: r.LibraryID, LibraryID: r.LibraryID,
CoverPath: r.CoverImagePath, CoverPath: r.CoverImagePath,
FormatGroup: r.FormatGroup, FormatGroup: r.FormatGroup,
+21 -28
View File
@@ -116,9 +116,9 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
"id": uuid.UUID(collection.ID.Bytes).String(), "id": uuid.UUID(collection.ID.Bytes).String(),
"user_id": uuid.UUID(collection.UserID.Bytes).String(), "user_id": uuid.UUID(collection.UserID.Bytes).String(),
"name": collection.Name, "name": collection.Name,
"description": textToString(collection.Description), "description": utils.PgTextString(collection.Description),
"color": textToString(collection.Color), "color": utils.PgTextString(collection.Color),
"icon": textToString(collection.Icon), "icon": utils.PgTextString(collection.Icon),
"collection_type": map[bool]string{true: "smart", false: "static"}[isSmart], "collection_type": map[bool]string{true: "smart", false: "static"}[isSmart],
"auto_assign_rules": collection.AutoAssignRules, "auto_assign_rules": collection.AutoAssignRules,
"view_settings": collection.ViewSettings, "view_settings": collection.ViewSettings,
@@ -204,9 +204,9 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
response = append(response, CollectionResponse{ response = append(response, CollectionResponse{
ID: col.ID.Bytes, ID: col.ID.Bytes,
Name: col.Name, Name: col.Name,
Description: textToString(col.Description), Description: utils.PgTextString(col.Description),
Color: textToString(col.Color), Color: utils.PgTextString(col.Color),
Icon: textToString(col.Icon), Icon: utils.PgTextString(col.Icon),
AutoAssignRules: col.AutoAssignRules, AutoAssignRules: col.AutoAssignRules,
CreatedAt: col.CreatedAt.Time.String(), CreatedAt: col.CreatedAt.Time.String(),
BookCount: bookCount, BookCount: bookCount,
@@ -325,9 +325,9 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
"id": uuid.UUID(collection.ID.Bytes).String(), "id": uuid.UUID(collection.ID.Bytes).String(),
"user_id": uuid.UUID(collection.UserID.Bytes).String(), "user_id": uuid.UUID(collection.UserID.Bytes).String(),
"name": collection.Name, "name": collection.Name,
"description": textToString(collection.Description), "description": utils.PgTextString(collection.Description),
"color": textToString(collection.Color), "color": utils.PgTextString(collection.Color),
"icon": textToString(collection.Icon), "icon": utils.PgTextString(collection.Icon),
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules), "auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
"view_settings": viewSettings, "view_settings": viewSettings,
"books": bookList, "books": bookList,
@@ -369,9 +369,9 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
"id": uuid.UUID(collection.ID.Bytes).String(), "id": uuid.UUID(collection.ID.Bytes).String(),
"user_id": uuid.UUID(collection.UserID.Bytes).String(), "user_id": uuid.UUID(collection.UserID.Bytes).String(),
"name": collection.Name, "name": collection.Name,
"description": textToString(collection.Description), "description": utils.PgTextString(collection.Description),
"color": textToString(collection.Color), "color": utils.PgTextString(collection.Color),
"icon": textToString(collection.Icon), "icon": utils.PgTextString(collection.Icon),
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules), "auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
"view_settings": json.RawMessage(collection.ViewSettings), "view_settings": json.RawMessage(collection.ViewSettings),
"created_at": collection.CreatedAt.Time.String(), "created_at": collection.CreatedAt.Time.String(),
@@ -580,8 +580,8 @@ func (h *CollectionHandler) GetDeviceMappings(c *echo.Context) error {
ID: m.ID.Bytes, ID: m.ID.Bytes,
CollectionID: m.CollectionID.Bytes, CollectionID: m.CollectionID.Bytes,
CollectionName: m.CollectionName, CollectionName: m.CollectionName,
DeviceShelfName: textToString(m.DeviceShelfName), DeviceShelfName: utils.PgTextString(m.DeviceShelfName),
SyncDirection: textToString(m.SyncDirection), SyncDirection: utils.PgTextString(m.SyncDirection),
CreatedAt: m.CreatedAt.Time.String(), CreatedAt: m.CreatedAt.Time.String(),
}) })
} }
@@ -627,8 +627,8 @@ func (h *CollectionHandler) CreateDeviceMapping(c *echo.Context) error {
"id": uuid.UUID(mapping.ID.Bytes).String(), "id": uuid.UUID(mapping.ID.Bytes).String(),
"collection_id": uuid.UUID(mapping.CollectionID.Bytes).String(), "collection_id": uuid.UUID(mapping.CollectionID.Bytes).String(),
"device_id": uuid.UUID(mapping.DeviceID.Bytes).String(), "device_id": uuid.UUID(mapping.DeviceID.Bytes).String(),
"device_shelf_name": textToString(mapping.DeviceShelfName), "device_shelf_name": utils.PgTextString(mapping.DeviceShelfName),
"sync_direction": textToString(mapping.SyncDirection), "sync_direction": utils.PgTextString(mapping.SyncDirection),
"created_at": mapping.CreatedAt.Time.String(), "created_at": mapping.CreatedAt.Time.String(),
}) })
} }
@@ -661,8 +661,8 @@ func (h *CollectionHandler) UpdateDeviceMapping(c *echo.Context) error {
"id": uuid.UUID(mapping.ID.Bytes).String(), "id": uuid.UUID(mapping.ID.Bytes).String(),
"collection_id": uuid.UUID(mapping.CollectionID.Bytes).String(), "collection_id": uuid.UUID(mapping.CollectionID.Bytes).String(),
"device_id": uuid.UUID(mapping.DeviceID.Bytes).String(), "device_id": uuid.UUID(mapping.DeviceID.Bytes).String(),
"device_shelf_name": textToString(mapping.DeviceShelfName), "device_shelf_name": utils.PgTextString(mapping.DeviceShelfName),
"sync_direction": textToString(mapping.SyncDirection), "sync_direction": utils.PgTextString(mapping.SyncDirection),
"created_at": mapping.CreatedAt.Time.String(), "created_at": mapping.CreatedAt.Time.String(),
}) })
} }
@@ -705,9 +705,9 @@ func (h *CollectionHandler) GetBookCollections(c *echo.Context) error {
response = append(response, CollectionResponse{ response = append(response, CollectionResponse{
ID: col.ID.Bytes, ID: col.ID.Bytes,
Name: col.Name, Name: col.Name,
Description: textToString(col.Description), Description: utils.PgTextString(col.Description),
Color: textToString(col.Color), Color: utils.PgTextString(col.Color),
Icon: textToString(col.Icon), Icon: utils.PgTextString(col.Icon),
}) })
} }
@@ -718,13 +718,6 @@ func (h *CollectionHandler) GetBookCollections(c *echo.Context) error {
}) })
} }
func textToString(t pgtype.Text) string {
if t.Valid {
return t.String
}
return ""
}
func (h *CollectionHandler) GetCollectionsData(c *echo.Context) ([]database.Collections, error) { func (h *CollectionHandler) GetCollectionsData(c *echo.Context) ([]database.Collections, error) {
user := c.Get("user").(database.Users) user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes) userUUID := uuid.UUID(user.ID.Bytes)
+2 -1
View File
@@ -4,6 +4,7 @@ import (
"testing" "testing"
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/utils"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -103,7 +104,7 @@ func TestTextToString(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
result := textToString(tt.input) result := utils.PgTextString(tt.input)
assert.Equal(t, tt.expected, result) assert.Equal(t, tt.expected, result)
}) })
} }
+2 -1
View File
@@ -5,6 +5,7 @@ import (
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/services" "bookhoard/internal/services"
"bookhoard/internal/utils"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
@@ -128,7 +129,7 @@ func TestTextToString_Valid(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
result := textToString(tt.input) result := utils.PgTextString(tt.input)
assert.Equal(t, tt.expected, result, "textToString result mismatch") assert.Equal(t, tt.expected, result, "textToString result mismatch")
}) })
} }
+39 -39
View File
@@ -815,44 +815,44 @@ func (mh *MediaHandler) ListMediaItems(c *echo.Context) error {
"id": uuid.UUID(item.ID.Bytes).String(), "id": uuid.UUID(item.ID.Bytes).String(),
"library_id": uuid.UUID(item.LibraryID.Bytes).String(), "library_id": uuid.UUID(item.LibraryID.Bytes).String(),
"title": item.Title, "title": item.Title,
"author": textToString(item.Author), "author": utils.PgTextString(item.Author),
"isbn": textToString(item.Isbn), "isbn": utils.PgTextString(item.Isbn),
"description": textToString(item.Description), "description": utils.PgTextString(item.Description),
"file_path": utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: item.FilePath != ""}), "file_path": utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: item.FilePath != ""}),
"file_size": item.FileSize, "file_size": item.FileSize,
"mime_type": textToString(item.MimeType), "mime_type": utils.PgTextString(item.MimeType),
"cover_image_path": utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), "cover_image_path": utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
"series": textToString(item.Series), "series": utils.PgTextString(item.Series),
"series_number": item.SeriesNumber, "series_number": item.SeriesNumber,
"tags": item.Tags, "tags": item.Tags,
"asin": textToString(item.Asin), "asin": utils.PgTextString(item.Asin),
"date_published": item.DatePublished.Time.Format("2006-01-02"), "date_published": item.DatePublished.Time.Format("2006-01-02"),
"publisher": textToString(item.Publisher), "publisher": utils.PgTextString(item.Publisher),
"contributors": item.Contributors, "contributors": item.Contributors,
"language": textToString(item.Language), "language": utils.PgTextString(item.Language),
"edition": textToString(item.Edition), "edition": utils.PgTextString(item.Edition),
"page_count": item.PageCount, "page_count": item.PageCount,
"genre": textToString(item.Genre), "genre": utils.PgTextString(item.Genre),
"created_at": item.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), "created_at": item.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
"updated_at": item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), "updated_at": item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
"format_group": item.FormatGroup, // already string "format_group": item.FormatGroup, // already string
// NEW: Reading direction fields // NEW: Reading direction fields
"manga_type": textToString(item.MangaType), "manga_type": utils.PgTextString(item.MangaType),
"reading_direction": textToString(item.ReadingDirection), "reading_direction": utils.PgTextString(item.ReadingDirection),
// NEW: Universal metadata fields // NEW: Universal metadata fields
"series_count": item.SeriesCount, "series_count": item.SeriesCount,
"volume": item.Volume, "volume": item.Volume,
"imprint": textToString(item.Imprint), "imprint": utils.PgTextString(item.Imprint),
"age_rating": textToString(item.AgeRating), "age_rating": utils.PgTextString(item.AgeRating),
"web_url": textToString(item.WebUrl), "web_url": utils.PgTextString(item.WebUrl),
"metadata_notes": textToString(item.MetadataNotes), "metadata_notes": utils.PgTextString(item.MetadataNotes),
"community_rating": numericToFloat(item.CommunityRating), "community_rating": numericToFloat(item.CommunityRating),
// NEW: Comic-specific fields // NEW: Comic-specific fields
"story_arc": textToString(item.StoryArc), "story_arc": utils.PgTextString(item.StoryArc),
"is_black_and_white": item.IsBlackAndWhite, "is_black_and_white": item.IsBlackAndWhite,
"alternate_info": jsonBytesToMap(item.AlternateInfo), "alternate_info": jsonBytesToMap(item.AlternateInfo),
"scan_information": textToString(item.ScanInformation), "scan_information": utils.PgTextString(item.ScanInformation),
"summary": textToString(item.Summary), "summary": utils.PgTextString(item.Summary),
} }
} }
return c.JSON(http.StatusOK, map[string]interface{}{"data": resolvedItems}) return c.JSON(http.StatusOK, map[string]interface{}{"data": resolvedItems})
@@ -887,47 +887,47 @@ func (mh *MediaHandler) GetMediaItem(c *echo.Context) error {
"id": uuid.UUID(item.ID.Bytes).String(), "id": uuid.UUID(item.ID.Bytes).String(),
"library_id": uuid.UUID(item.LibraryID.Bytes).String(), "library_id": uuid.UUID(item.LibraryID.Bytes).String(),
"title": item.Title, "title": item.Title,
"author": textToString(item.Author), "author": utils.PgTextString(item.Author),
"isbn": textToString(item.Isbn), "isbn": utils.PgTextString(item.Isbn),
"description": textToString(item.Description), "description": utils.PgTextString(item.Description),
"file_path": utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: item.FilePath != ""}), "file_path": utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: item.FilePath != ""}),
"file_size": item.FileSize, "file_size": item.FileSize,
"mime_type": textToString(item.MimeType), "mime_type": utils.PgTextString(item.MimeType),
"cover_image_path": utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), "cover_image_path": utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
"series": textToString(item.Series), "series": utils.PgTextString(item.Series),
"series_number": item.SeriesNumber, "series_number": item.SeriesNumber,
"tags": item.Tags, "tags": item.Tags,
"asin": textToString(item.Asin), "asin": utils.PgTextString(item.Asin),
"date_published": item.DatePublished.Time.Format("2006-01-02"), "date_published": item.DatePublished.Time.Format("2006-01-02"),
"publisher": textToString(item.Publisher), "publisher": utils.PgTextString(item.Publisher),
"contributors": item.Contributors, "contributors": item.Contributors,
"language": textToString(item.Language), "language": utils.PgTextString(item.Language),
"edition": textToString(item.Edition), "edition": utils.PgTextString(item.Edition),
"page_count": item.PageCount, "page_count": item.PageCount,
"genre": textToString(item.Genre), "genre": utils.PgTextString(item.Genre),
"created_at": item.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), "created_at": item.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
"updated_at": item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), "updated_at": item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
"format_group": item.FormatGroup, "format_group": item.FormatGroup,
"format_mimetype": textToString(item.FormatMimetype), "format_mimetype": utils.PgTextString(item.FormatMimetype),
"narrators": item.Narrators, "narrators": item.Narrators,
"abridged": item.Abridged, "abridged": item.Abridged,
// NEW: Reading direction fields // NEW: Reading direction fields
"manga_type": textToString(item.MangaType), "manga_type": utils.PgTextString(item.MangaType),
"reading_direction": textToString(item.ReadingDirection), "reading_direction": utils.PgTextString(item.ReadingDirection),
// NEW: Universal metadata fields // NEW: Universal metadata fields
"series_count": item.SeriesCount, "series_count": item.SeriesCount,
"volume": item.Volume, "volume": item.Volume,
"imprint": textToString(item.Imprint), "imprint": utils.PgTextString(item.Imprint),
"age_rating": textToString(item.AgeRating), "age_rating": utils.PgTextString(item.AgeRating),
"web_url": textToString(item.WebUrl), "web_url": utils.PgTextString(item.WebUrl),
"metadata_notes": textToString(item.MetadataNotes), "metadata_notes": utils.PgTextString(item.MetadataNotes),
"community_rating": numericToFloat(item.CommunityRating), "community_rating": numericToFloat(item.CommunityRating),
// NEW: Comic-specific fields // NEW: Comic-specific fields
"story_arc": textToString(item.StoryArc), "story_arc": utils.PgTextString(item.StoryArc),
"is_black_and_white": item.IsBlackAndWhite, "is_black_and_white": item.IsBlackAndWhite,
"alternate_info": jsonBytesToMap(item.AlternateInfo), "alternate_info": jsonBytesToMap(item.AlternateInfo),
"scan_information": textToString(item.ScanInformation), "scan_information": utils.PgTextString(item.ScanInformation),
"summary": textToString(item.Summary), "summary": utils.PgTextString(item.Summary),
"library_type_name": textPtrToString(item.LibraryTypeName), "library_type_name": textPtrToString(item.LibraryTypeName),
}) })
} }
+4 -3
View File
@@ -3,6 +3,7 @@ package handlers
import ( import (
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/services" "bookhoard/internal/services"
"bookhoard/internal/utils"
"context" "context"
"errors" "errors"
"fmt" "fmt"
@@ -124,10 +125,10 @@ func (h *ReaderHandler) ShowReader(c *echo.Context) error {
return c.JSON(http.StatusOK, map[string]interface{}{ return c.JSON(http.StatusOK, map[string]interface{}{
"media_item_id": mediaItemID, "media_item_id": mediaItemID,
"title": mediaItem.Title, "title": mediaItem.Title,
"author": textToString(mediaItem.Author), "author": utils.PgTextString(mediaItem.Author),
"cover_image_path": textToString(mediaItem.CoverImagePath), "cover_image_path": utils.PgTextString(mediaItem.CoverImagePath),
"library_type": mediaItem.FormatGroup, "library_type": mediaItem.FormatGroup,
"mime_type": textToString(mediaItem.MimeType), "mime_type": utils.PgTextString(mediaItem.MimeType),
"file_path": mediaItem.FilePath, "file_path": mediaItem.FilePath,
"total_pages": mediaItem.PageCount, "total_pages": mediaItem.PageCount,
"chapter_count": mediaItem.ChapterCount, "chapter_count": mediaItem.ChapterCount,
+3 -1
View File
@@ -3,6 +3,8 @@ package handlers
import ( import (
"testing" "testing"
"bookhoard/internal/utils"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -38,7 +40,7 @@ func TestSeriesHandler_TextToStringConversion(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
result := textToString(tt.input) result := utils.PgTextString(tt.input)
assert.Equal(t, tt.expected, result) assert.Equal(t, tt.expected, result)
}) })
} }
+3 -2
View File
@@ -3,6 +3,7 @@ package router
import ( import (
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/handlers" "bookhoard/internal/handlers"
"bookhoard/internal/utils"
"bookhoard/templates" "bookhoard/templates"
"bytes" "bytes"
"context" "context"
@@ -213,7 +214,7 @@ func renderLibraryList(c *echo.Context, cfg *Config) error {
libData[i] = templates.LibraryData{ libData[i] = templates.LibraryData{
ID: libUUID.String(), ID: libUUID.String(),
Name: lib.Name, Name: lib.Name,
Description: getText(lib.Description), Description: utils.PgTextString(lib.Description),
TypeName: lib.TypeName, TypeName: lib.TypeName,
TypeValue: lib.TypeName, TypeValue: lib.TypeName,
FolderCount: folderCount, FolderCount: folderCount,
@@ -262,7 +263,7 @@ func renderLibraryPanelWithError(c *echo.Context, cfg *Config, libraryID pgtype.
libData := templates.LibraryData{ libData := templates.LibraryData{
ID: libraryIDStr, ID: libraryIDStr,
Name: lib.Name, Name: lib.Name,
Description: getText(lib.Description), Description: utils.PgTextString(lib.Description),
TypeName: lib.TypeName, TypeName: lib.TypeName,
TypeValue: lib.TypeName, TypeValue: lib.TypeName,
} }
+20 -20
View File
@@ -552,9 +552,9 @@ func registerFrontendRoutes(cfg *Config) {
colData[i] = templates.CollectionData{ colData[i] = templates.CollectionData{
ID: uuid.UUID(col.ID.Bytes).String(), ID: uuid.UUID(col.ID.Bytes).String(),
Name: col.Name, Name: col.Name,
Description: getText(col.Description), Description: utils.PgTextString(col.Description),
Color: getText(col.Color), Color: utils.PgTextString(col.Color),
Icon: getText(col.Icon), Icon: utils.PgTextString(col.Icon),
IsSystem: col.IsSystemCollection.Bool, IsSystem: col.IsSystemCollection.Bool,
IsSmart: isSmart, IsSmart: isSmart,
BookCount: bookCount, BookCount: bookCount,
@@ -598,9 +598,9 @@ func registerFrontendRoutes(cfg *Config) {
colData := templates.CollectionData{ colData := templates.CollectionData{
ID: uuid.UUID(collection.ID.Bytes).String(), ID: uuid.UUID(collection.ID.Bytes).String(),
Name: collection.Name, Name: collection.Name,
Description: getText(collection.Description), Description: utils.PgTextString(collection.Description),
Color: getText(collection.Color), Color: utils.PgTextString(collection.Color),
Icon: getText(collection.Icon), Icon: utils.PgTextString(collection.Icon),
} }
// Ensure default color if empty // Ensure default color if empty
@@ -652,9 +652,9 @@ func registerFrontendRoutes(cfg *Config) {
colData := templates.CollectionData{ colData := templates.CollectionData{
ID: collectionID, ID: collectionID,
Name: collection.Name, Name: collection.Name,
Description: getText(collection.Description), Description: utils.PgTextString(collection.Description),
Color: getText(collection.Color), Color: utils.PgTextString(collection.Color),
Icon: getText(collection.Icon), Icon: utils.PgTextString(collection.Icon),
IsSystem: collection.IsSystemCollection.Bool, IsSystem: collection.IsSystemCollection.Bool,
IsSmart: cfg.CollectionHandler.CollectionService().IsSmartCollection(collection.AutoAssignRules), IsSmart: cfg.CollectionHandler.CollectionService().IsSmartCollection(collection.AutoAssignRules),
} }
@@ -861,7 +861,7 @@ func registerFrontendRoutes(cfg *Config) {
libData[i] = templates.LibraryData{ libData[i] = templates.LibraryData{
ID: libUUID.String(), ID: libUUID.String(),
Name: lib.Name, Name: lib.Name,
Description: getText(lib.Description), Description: utils.PgTextString(lib.Description),
TypeName: lib.TypeName, TypeName: lib.TypeName,
} }
} }
@@ -1056,7 +1056,7 @@ func registerFrontendRoutes(cfg *Config) {
item := templates.ArchivedItem{ item := templates.ArchivedItem{
ID: itemUUID.String(), ID: itemUUID.String(),
Title: row.Title, Title: row.Title,
Author: getText(row.Author), Author: utils.PgTextString(row.Author),
LibraryName: row.LibraryName, LibraryName: row.LibraryName,
FilePath: row.FilePath, FilePath: row.FilePath,
MissingScans: row.MissingScanCount, MissingScans: row.MissingScanCount,
@@ -1064,7 +1064,7 @@ func registerFrontendRoutes(cfg *Config) {
if row.MatchID.Valid { if row.MatchID.Valid {
item.MatchID = uuid.UUID(row.MatchID.Bytes).String() item.MatchID = uuid.UUID(row.MatchID.Bytes).String()
item.MatchTitle = row.MatchTitle item.MatchTitle = row.MatchTitle
item.MatchLibraryName = getText(row.MatchLibraryName) item.MatchLibraryName = utils.PgTextString(row.MatchLibraryName)
} }
if row.ArchivedAt.Valid { if row.ArchivedAt.Valid {
archived := row.ArchivedAt.Time archived := row.ArchivedAt.Time
@@ -1111,7 +1111,7 @@ func registerFrontendRoutes(cfg *Config) {
libData[i] = templates.LibraryData{ libData[i] = templates.LibraryData{
ID: libUUID.String(), ID: libUUID.String(),
Name: lib.Name, Name: lib.Name,
Description: getText(lib.Description), Description: utils.PgTextString(lib.Description),
TypeName: lib.TypeName, TypeName: lib.TypeName,
TypeValue: lib.TypeName, TypeValue: lib.TypeName,
FolderCount: len(folders), FolderCount: len(folders),
@@ -1292,9 +1292,9 @@ func registerFrontendRoutes(cfg *Config) {
Username: u.Username, Username: u.Username,
Email: u.Email, Email: u.Email,
Role: u.Role, Role: u.Role,
Theme: getText(u.Theme), Theme: utils.PgTextString(u.Theme),
FirstName: getText(u.FirstName), FirstName: utils.PgTextString(u.FirstName),
LastName: getText(u.LastName), LastName: utils.PgTextString(u.LastName),
CreatedAt: u.CreatedAt.Time, CreatedAt: u.CreatedAt.Time,
} }
} }
@@ -1401,13 +1401,13 @@ func registerFrontendRoutes(cfg *Config) {
Username: targetUser.Username, Username: targetUser.Username,
Email: targetUser.Email, Email: targetUser.Email,
Role: targetUser.Role, Role: targetUser.Role,
Theme: getText(targetUser.Theme), Theme: utils.PgTextString(targetUser.Theme),
FirstName: getText(targetUser.FirstName), FirstName: utils.PgTextString(targetUser.FirstName),
LastName: getText(targetUser.LastName), LastName: utils.PgTextString(targetUser.LastName),
CreatedAt: targetUser.CreatedAt.Time, CreatedAt: targetUser.CreatedAt.Time,
// Without this the timezone select rendered UTC selected for // Without this the timezone select rendered UTC selected for
// EVERY user — saves landed, the modal just never showed them. // EVERY user — saves landed, the modal just never showed them.
Timezone: getText(targetUser.Timezone), Timezone: utils.PgTextString(targetUser.Timezone),
} }
// Render modal // Render modal
+4 -10
View File
@@ -10,6 +10,7 @@ import (
"time" "time"
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/utils"
"bookhoard/templates" "bookhoard/templates"
"github.com/google/uuid" "github.com/google/uuid"
@@ -60,8 +61,8 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
Theme: userTheme, Theme: userTheme,
Token: token, Token: token,
Timezone: userTimezone, Timezone: userTimezone,
FirstName: getText(userDB.FirstName), FirstName: utils.PgTextString(userDB.FirstName),
LastName: getText(userDB.LastName), LastName: utils.PgTextString(userDB.LastName),
CreatedAt: userDB.CreatedAt.Time, CreatedAt: userDB.CreatedAt.Time,
}, nil }, nil
} }
@@ -135,13 +136,6 @@ func folderReachableCached(path string) bool {
return exists return exists
} }
func getText(t pgtype.Text) string {
if t.Valid {
return t.String
}
return ""
}
func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolution { func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolution {
res := LibraryResolution{} res := LibraryResolution{}
@@ -195,7 +189,7 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
res.Libraries[i] = templates.LibraryData{ res.Libraries[i] = templates.LibraryData{
ID: libUUID.String(), ID: libUUID.String(),
Name: lib.Name, Name: lib.Name,
Description: getText(lib.Description), Description: utils.PgTextString(lib.Description),
TypeName: lib.TypeName, TypeName: lib.TypeName,
TypeValue: lib.TypeName, TypeValue: lib.TypeName,
MediaCount: countMap[libUUID.String()], MediaCount: countMap[libUUID.String()],
+4 -4
View File
@@ -82,19 +82,19 @@ func registerReaderRoutes(cfg *Config) {
metadata := templates.ReaderMetadata{ metadata := templates.ReaderMetadata{
MediaItemID: mediaUUID.String(), MediaItemID: mediaUUID.String(),
Title: mediaItem.Title, Title: mediaItem.Title,
Author: textToString(mediaItem.Author), Author: utils.PgTextString(mediaItem.Author),
// Sidecar covers store a raw library-relative path; resolve to // Sidecar covers store a raw library-relative path; resolve to
// the cookie-authed /uploads URL (idempotent for items whose // the cookie-authed /uploads URL (idempotent for items whose
// stored value is already a servable path). // stored value is already a servable path).
CoverImagePath: utils.ResolveMediaURL(mediaItem.LibraryID, mediaItem.CoverImagePath), CoverImagePath: utils.ResolveMediaURL(mediaItem.LibraryID, mediaItem.CoverImagePath),
LibraryType: mediaItem.FormatGroup, LibraryType: mediaItem.FormatGroup,
MimeType: textToString(mediaItem.MimeType), MimeType: utils.PgTextString(mediaItem.MimeType),
FilePath: mediaItem.FilePath, FilePath: mediaItem.FilePath,
TotalPages: int(mediaItem.PageCount.Int32), TotalPages: int(mediaItem.PageCount.Int32),
ChapterCount: int(mediaItem.ChapterCount.Int32), ChapterCount: int(mediaItem.ChapterCount.Int32),
FormatGroup: mediaItem.FormatGroup, FormatGroup: mediaItem.FormatGroup,
MangaType: textToString(mediaItem.MangaType), MangaType: utils.PgTextString(mediaItem.MangaType),
ReadingDirection: textToString(mediaItem.ReadingDirection), ReadingDirection: utils.PgTextString(mediaItem.ReadingDirection),
LibraryID: libUUID.String(), LibraryID: libUUID.String(),
FileURL: utils.ResolveMediaURL(mediaItem.LibraryID, pgtype.Text{String: mediaItem.FilePath, Valid: true}), FileURL: utils.ResolveMediaURL(mediaItem.LibraryID, pgtype.Text{String: mediaItem.FilePath, Valid: true}),
TotalCharacters: mediaItem.TotalCharacters.Int64, TotalCharacters: mediaItem.TotalCharacters.Int64,
-8
View File
@@ -116,11 +116,3 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error {
} }
return c.HTML(http.StatusOK, buf.String()) return c.HTML(http.StatusOK, buf.String())
} }
// textToString converts pgtype.Text to string
func textToString(t pgtype.Text) string {
if t.Valid {
return t.String
}
return ""
}
+10
View File
@@ -33,3 +33,13 @@ func ResolveMediaURL(libraryID pgtype.UUID, relativePath pgtype.Text) string {
encodedPath := strings.Join(segments, "/") encodedPath := strings.Join(segments, "/")
return fmt.Sprintf("/uploads/library-%s/%s", libraryIDStr, encodedPath) return fmt.Sprintf("/uploads/library-%s/%s", libraryIDStr, encodedPath)
} }
// PgTextString unwraps a nullable text column to a plain string (""
// when NULL). The former textToString/getText/normText copies in
// handlers, router, and utils all did exactly this.
func PgTextString(t pgtype.Text) string {
if t.Valid {
return t.String
}
return ""
}