feat: migrate tags and contributors from TEXT to TEXT[] arrays

Convert tags and contributors columns from comma-separated strings to PostgreSQL
TEXT[] arrays for better data normalization and query performance.

Database Changes:
- schema.sql: Change tags/contributors from TEXT to TEXT[]
- schema.sql: Add GIN indexes for fast array searches
- queries.sql: Update search queries to use ANY() operator
- queries.sql: Update fuzzy search with unnest() for arrays

Generated Code (sqlc):
- models.go: Auto-generated with []string types for tags/contributors
- queries.sql.go: Auto-generated with proper array handling

Handler Changes:
- media.go: Update request structs to use []string for tags/contributors
- media.go: Remove pgtype.Text wrapping, use direct array assignment
- media.go: Add tag normalization in CreateMediaItemHandler
- collections.go: Update tags evaluation to join arrays for comparison
- collections.go: Add strings import for Join() function

Service Changes:
- ebook_scanner.go: Update EbookMetadata struct to use []string
- ebook_scanner.go: Remove string Join(), assign arrays directly
- collection_service.go: Update tags rule evaluation to join arrays
- collection_service.go: Add strings import

New Utilities:
- internal/utils/tags.go: Create NormalizeTags(), JoinTags(), SplitTags()
- Normalizes tags by trimming, lowercasing, removing duplicates/empties

API Documentation:
- bruno/media-items/Create Media Item.bru: Update examples to use arrays
- bruno/media-items/Update Media Item.bru: Update examples to use arrays
- Update docs: tags/contributors now array of string

Breaking Change:
- JSON format changes from "tags": "tag1,tag2" to "tags": ["tag1", "tag2"]
- Tests already use array format (no changes needed)

Benefits:
- GIN indexes enable faster array searches
- Normalization prevents data quality issues (case, duplicates)
- Array operations use PostgreSQL native operators (ANY, &&, unnest)
- Better separation of concerns (no string parsing in application)
This commit is contained in:
2026-02-07 22:53:12 -05:00
parent 98f2913eb5
commit 516cec5a7f
13 changed files with 1831 additions and 229 deletions
+2 -123
View File
@@ -76,127 +76,6 @@ type Devices struct {
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
type EbookHighlights struct {
ID pgtype.UUID `db:"id" json:"id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
SelectionText string `db:"selection_text" json:"selection_text"`
StartPosition pgtype.Text `db:"start_position" json:"start_position"`
EndPosition pgtype.Text `db:"end_position" json:"end_position"`
Color pgtype.Text `db:"color" json:"color"`
NoteID pgtype.UUID `db:"note_id" json:"note_id"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"`
PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_end"`
CharacterStart pgtype.Int4 `db:"character_start" json:"character_start"`
CharacterEnd pgtype.Int4 `db:"character_end" json:"character_end"`
EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"`
EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"`
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
ParagraphStart pgtype.Int4 `db:"paragraph_start" json:"paragraph_start"`
ParagraphEnd pgtype.Int4 `db:"paragraph_end" json:"paragraph_end"`
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
}
type EbookNotes struct {
ID pgtype.UUID `db:"id" json:"id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
Content string `db:"content" json:"content"`
Position pgtype.Text `db:"position" json:"position"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
PercentageLocation pgtype.Float8 `db:"percentage_location" json:"percentage_location"`
CharacterStart pgtype.Int4 `db:"character_start" json:"character_start"`
CharacterEnd pgtype.Int4 `db:"character_end" json:"character_end"`
EpubcfiLocation pgtype.Text `db:"epubcfi_location" json:"epubcfi_location"`
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
ParagraphReference pgtype.Int4 `db:"paragraph_reference" json:"paragraph_reference"`
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
}
type EbookRatings struct {
ID pgtype.UUID `db:"id" json:"id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
Rating int32 `db:"rating" json:"rating"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
}
type EbookReadingProgress struct {
ID pgtype.UUID `db:"id" json:"id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
CurrentPage pgtype.Int4 `db:"current_page" json:"current_page"`
TotalPages pgtype.Int4 `db:"total_pages" json:"total_pages"`
LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"`
Percentage pgtype.Float8 `db:"percentage" json:"percentage"`
CharacterOffset pgtype.Int8 `db:"character_offset" json:"character_offset"`
Epubcfi pgtype.Text `db:"epubcfi" json:"epubcfi"`
Chapter pgtype.Int4 `db:"chapter" json:"chapter"`
ChapterProgress pgtype.Float8 `db:"chapter_progress" json:"chapter_progress"`
ViewportX pgtype.Float8 `db:"viewport_x" json:"viewport_x"`
ViewportY pgtype.Float8 `db:"viewport_y" json:"viewport_y"`
ZoomLevel pgtype.Float8 `db:"zoom_level" json:"zoom_level"`
ScrollPositionX pgtype.Float8 `db:"scroll_position_x" json:"scroll_position_x"`
ScrollPositionY pgtype.Float8 `db:"scroll_position_y" json:"scroll_position_y"`
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
ReadingMode pgtype.Text `db:"reading_mode" json:"reading_mode"`
LastSyncDevice pgtype.Text `db:"last_sync_device" json:"last_sync_device"`
LastSyncSource pgtype.Text `db:"last_sync_source" json:"last_sync_source"`
LastSyncTimestamp pgtype.Timestamptz `db:"last_sync_timestamp" json:"last_sync_timestamp"`
ConflictDetected pgtype.Bool `db:"conflict_detected" json:"conflict_detected"`
ConflictResolved pgtype.Bool `db:"conflict_resolved" json:"conflict_resolved"`
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
}
type Ebooks struct {
ID pgtype.UUID `db:"id" json:"id"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
Title string `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
Isbn pgtype.Text `db:"isbn" json:"isbn"`
Description pgtype.Text `db:"description" json:"description"`
FilePath string `db:"file_path" json:"file_path"`
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
Series pgtype.Text `db:"series" json:"series"`
SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"`
Tags pgtype.Text `db:"tags" json:"tags"`
Asin pgtype.Text `db:"asin" json:"asin"`
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
Publisher pgtype.Text `db:"publisher" json:"publisher"`
Contributors pgtype.Text `db:"contributors" json:"contributors"`
Language pgtype.Text `db:"language" json:"language"`
Edition pgtype.Text `db:"edition" json:"edition"`
PageCount pgtype.Int4 `db:"page_count" json:"page_count"`
Genre pgtype.Text `db:"genre" json:"genre"`
CopyrightYear pgtype.Int4 `db:"copyright_year" json:"copyright_year"`
GoodreadsID pgtype.Text `db:"goodreads_id" json:"goodreads_id"`
OpenlibraryID pgtype.Text `db:"openlibrary_id" json:"openlibrary_id"`
GoogleBooksID pgtype.Text `db:"google_books_id" json:"google_books_id"`
AddedByAdminID pgtype.UUID `db:"added_by_admin_id" json:"added_by_admin_id"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
FormatGroup string `db:"format_group" json:"format_group"`
FormatMimetype pgtype.Text `db:"format_mimetype" json:"format_mimetype"`
IsReflowable pgtype.Bool `db:"is_reflowable" json:"is_reflowable"`
HasFixedLayout pgtype.Bool `db:"has_fixed_layout" json:"has_fixed_layout"`
TotalCharacters pgtype.Int8 `db:"total_characters" json:"total_characters"`
ChapterCount pgtype.Int4 `db:"chapter_count" json:"chapter_count"`
EntitlementID pgtype.Text `db:"entitlement_id" json:"entitlement_id"`
RevisionNumber pgtype.Int4 `db:"revision_number" json:"revision_number"`
KoboContentID pgtype.Text `db:"kobo_content_id" json:"kobo_content_id"`
KoboMetadata []byte `db:"kobo_metadata" json:"kobo_metadata"`
}
type KoboEntitlements struct {
ID pgtype.UUID `db:"id" json:"id"`
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
@@ -308,11 +187,11 @@ type MediaItems struct {
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
Series pgtype.Text `db:"series" json:"series"`
SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"`
Tags pgtype.Text `db:"tags" json:"tags"`
Tags []string `db:"tags" json:"tags"`
Asin pgtype.Text `db:"asin" json:"asin"`
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
Publisher pgtype.Text `db:"publisher" json:"publisher"`
Contributors pgtype.Text `db:"contributors" json:"contributors"`
Contributors []string `db:"contributors" json:"contributors"`
// ISO 639-1 language code (e.g., en, es, fr)
Language pgtype.Text `db:"language" json:"language"`
// Edition information (e.g., "First Edition", "Revised Edition")
+57 -41
View File
@@ -342,7 +342,7 @@ RETURNING id, device_id, media_item_id, bookhoard_uuid, kobo_content_id, content
type CreateDeviceCatalogParams struct {
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
BookhoardUuid pgtype.UUID `db:"bookhoard_uuid" json:"bookhoard_uuid"`
BookhoardUuid pgtype.UUID `db:"bookhoard_uuid" json:"bookhoard_uuid"`
KoboContentID string `db:"kobo_content_id" json:"kobo_content_id"`
ContentIDType pgtype.Text `db:"content_id_type" json:"content_id_type"`
Available pgtype.Bool `db:"available" json:"available"`
@@ -602,11 +602,11 @@ type CreateMediaItemParams struct {
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
Series pgtype.Text `db:"series" json:"series"`
SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"`
Tags pgtype.Text `db:"tags" json:"tags"`
Tags []string `db:"tags" json:"tags"`
Asin pgtype.Text `db:"asin" json:"asin"`
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
Publisher pgtype.Text `db:"publisher" json:"publisher"`
Contributors pgtype.Text `db:"contributors" json:"contributors"`
Contributors []string `db:"contributors" json:"contributors"`
Language pgtype.Text `db:"language" json:"language"`
Edition pgtype.Text `db:"edition" json:"edition"`
PageCount pgtype.Int4 `db:"page_count" json:"page_count"`
@@ -1824,7 +1824,7 @@ SELECT id, device_id, media_item_id, bookhoard_uuid, kobo_content_id, content_id
`
type GetDeviceCatalogByBookhoardUUIDParams struct {
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
BookhoardUuid pgtype.UUID `db:"bookhoard_uuid" json:"bookhoard_uuid"`
}
@@ -1880,7 +1880,7 @@ type GetDeviceCatalogEntriesRow struct {
ID pgtype.UUID `db:"id" json:"id"`
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
BookhoardUuid pgtype.UUID `db:"bookhoard_uuid" json:"bookhoard_uuid"`
BookhoardUuid pgtype.UUID `db:"bookhoard_uuid" json:"bookhoard_uuid"`
KoboContentID string `db:"kobo_content_id" json:"kobo_content_id"`
ContentIDType pgtype.Text `db:"content_id_type" json:"content_id_type"`
Available pgtype.Bool `db:"available" json:"available"`
@@ -4931,11 +4931,11 @@ type ListMediaItemsRow struct {
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
Series pgtype.Text `db:"series" json:"series"`
SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"`
Tags pgtype.Text `db:"tags" json:"tags"`
Tags []string `db:"tags" json:"tags"`
Asin pgtype.Text `db:"asin" json:"asin"`
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
Publisher pgtype.Text `db:"publisher" json:"publisher"`
Contributors pgtype.Text `db:"contributors" json:"contributors"`
Contributors []string `db:"contributors" json:"contributors"`
Language pgtype.Text `db:"language" json:"language"`
Edition pgtype.Text `db:"edition" json:"edition"`
PageCount pgtype.Int4 `db:"page_count" json:"page_count"`
@@ -5052,11 +5052,11 @@ type ListMediaItemsByLibraryRow struct {
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
Series pgtype.Text `db:"series" json:"series"`
SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"`
Tags pgtype.Text `db:"tags" json:"tags"`
Tags []string `db:"tags" json:"tags"`
Asin pgtype.Text `db:"asin" json:"asin"`
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
Publisher pgtype.Text `db:"publisher" json:"publisher"`
Contributors pgtype.Text `db:"contributors" json:"contributors"`
Contributors []string `db:"contributors" json:"contributors"`
Language pgtype.Text `db:"language" json:"language"`
Edition pgtype.Text `db:"edition" json:"edition"`
PageCount pgtype.Int4 `db:"page_count" json:"page_count"`
@@ -5223,11 +5223,11 @@ type ListMediaItemsFilteredRow struct {
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
Series pgtype.Text `db:"series" json:"series"`
SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"`
Tags pgtype.Text `db:"tags" json:"tags"`
Tags []string `db:"tags" json:"tags"`
Asin pgtype.Text `db:"asin" json:"asin"`
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
Publisher pgtype.Text `db:"publisher" json:"publisher"`
Contributors pgtype.Text `db:"contributors" json:"contributors"`
Contributors []string `db:"contributors" json:"contributors"`
Language pgtype.Text `db:"language" json:"language"`
Edition pgtype.Text `db:"edition" json:"edition"`
PageCount pgtype.Int4 `db:"page_count" json:"page_count"`
@@ -5430,11 +5430,11 @@ type ListMediaItemsSortedRow struct {
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
Series pgtype.Text `db:"series" json:"series"`
SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"`
Tags pgtype.Text `db:"tags" json:"tags"`
Tags []string `db:"tags" json:"tags"`
Asin pgtype.Text `db:"asin" json:"asin"`
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
Publisher pgtype.Text `db:"publisher" json:"publisher"`
Contributors pgtype.Text `db:"contributors" json:"contributors"`
Contributors []string `db:"contributors" json:"contributors"`
Language pgtype.Text `db:"language" json:"language"`
Edition pgtype.Text `db:"edition" json:"edition"`
PageCount pgtype.Int4 `db:"page_count" json:"page_count"`
@@ -6052,16 +6052,16 @@ WHERE COALESCE(lv.is_visible, true) = true
AND (
mi.title ILIKE $2 OR
mi.author ILIKE $2 OR
mi.series ILIKE $2 OR
mi.tags ILIKE $2 OR
mi.contributors ILIKE $2
mi.series ILIKE $2 OR
$2 = ANY(mi.tags) OR
$2 = ANY(mi.contributors)
)
ORDER BY
CASE
CASE
WHEN mi.title ILIKE $2 THEN 1
WHEN mi.author ILIKE $2 THEN 2
WHEN mi.series ILIKE $2 THEN 3
WHEN mi.tags ILIKE $2 THEN 4
WHEN $2 = ANY(mi.tags) THEN 4
ELSE 5
END,
mi.title ASC
@@ -6088,11 +6088,11 @@ type SearchMediaItemsRow struct {
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
Series pgtype.Text `db:"series" json:"series"`
SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"`
Tags pgtype.Text `db:"tags" json:"tags"`
Tags []string `db:"tags" json:"tags"`
Asin pgtype.Text `db:"asin" json:"asin"`
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
Publisher pgtype.Text `db:"publisher" json:"publisher"`
Contributors pgtype.Text `db:"contributors" json:"contributors"`
Contributors []string `db:"contributors" json:"contributors"`
Language pgtype.Text `db:"language" json:"language"`
Edition pgtype.Text `db:"edition" json:"edition"`
PageCount pgtype.Int4 `db:"page_count" json:"page_count"`
@@ -6202,23 +6202,39 @@ JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
WHERE COALESCE(lv.is_visible, true) = true
AND (
word_similarity($2, mi.title) > 0.3 OR
word_similarity($2, COALESCE(mi.author, '')) > 0.3 OR
word_similarity($2, COALESCE(mi.series, '')) > 0.3 OR
word_similarity($2, COALESCE(mi.tags, '')) > 0.3 OR
word_similarity($2, COALESCE(mi.contributors, '')) > 0.3
)
ORDER BY
GREATEST(
word_similarity($2, mi.title),
word_similarity($2, COALESCE(mi.author, '')),
word_similarity($2, COALESCE(mi.series, '')),
word_similarity($2, COALESCE(mi.tags, '')),
word_similarity($2, COALESCE(mi.contributors, ''))
) DESC,
mi.title ASC
LIMIT $4 OFFSET $3
AND (
word_similarity($2, mi.title) > 0.3 OR
word_similarity($2, COALESCE(mi.author, '')) > 0.3 OR
word_similarity($2, COALESCE(mi.series, '')) > 0.3 OR
EXISTS (
SELECT 1 FROM unnest(mi.tags) AS tag
WHERE word_similarity($2, tag) > 0.3
LIMIT 1
) OR
EXISTS (
SELECT 1 FROM unnest(mi.contributors) AS contributor
WHERE word_similarity($2, contributor) > 0.3
LIMIT 1
)
)
ORDER BY
GREATEST(
word_similarity($2, mi.title),
word_similarity($2, COALESCE(mi.author, '')),
word_similarity($2, COALESCE(mi.series, '')),
COALESCE(
(SELECT MAX(word_similarity($2, tag))
FROM unnest(mi.tags) AS tag),
0
),
COALESCE(
(SELECT MAX(word_similarity($2, contributor))
FROM unnest(mi.contributors) AS contributor),
0
)
) DESC,
mi.title ASC
LIMIT $4 OFFSET $3
`
type SearchMediaItemsFuzzyParams struct {
@@ -6241,11 +6257,11 @@ type SearchMediaItemsFuzzyRow struct {
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
Series pgtype.Text `db:"series" json:"series"`
SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"`
Tags pgtype.Text `db:"tags" json:"tags"`
Tags []string `db:"tags" json:"tags"`
Asin pgtype.Text `db:"asin" json:"asin"`
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
Publisher pgtype.Text `db:"publisher" json:"publisher"`
Contributors pgtype.Text `db:"contributors" json:"contributors"`
Contributors []string `db:"contributors" json:"contributors"`
Language pgtype.Text `db:"language" json:"language"`
Edition pgtype.Text `db:"edition" json:"edition"`
PageCount pgtype.Int4 `db:"page_count" json:"page_count"`
@@ -6946,11 +6962,11 @@ type UpdateMediaItemParams struct {
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
Series pgtype.Text `db:"series" json:"series"`
SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"`
Tags pgtype.Text `db:"tags" json:"tags"`
Tags []string `db:"tags" json:"tags"`
Asin pgtype.Text `db:"asin" json:"asin"`
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
Publisher pgtype.Text `db:"publisher" json:"publisher"`
Contributors pgtype.Text `db:"contributors" json:"contributors"`
Contributors []string `db:"contributors" json:"contributors"`
Language pgtype.Text `db:"language" json:"language"`
Edition pgtype.Text `db:"edition" json:"edition"`
PageCount pgtype.Int4 `db:"page_count" json:"page_count"`
+38 -22
View File
@@ -363,16 +363,16 @@ WHERE COALESCE(lv.is_visible, true) = true
AND (
mi.title ILIKE sqlc.narg('search_pattern') OR
mi.author ILIKE sqlc.narg('search_pattern') OR
mi.series ILIKE sqlc.narg('search_pattern') OR
mi.tags ILIKE sqlc.narg('search_pattern') OR
mi.contributors ILIKE sqlc.narg('search_pattern')
mi.series ILIKE sqlc.narg('search_pattern') OR
sqlc.narg('search_pattern') = ANY(mi.tags) OR
sqlc.narg('search_pattern') = ANY(mi.contributors)
)
ORDER BY
CASE
CASE
WHEN mi.title ILIKE sqlc.narg('search_pattern') THEN 1
WHEN mi.author ILIKE sqlc.narg('search_pattern') THEN 2
WHEN mi.series ILIKE sqlc.narg('search_pattern') THEN 3
WHEN mi.tags ILIKE sqlc.narg('search_pattern') THEN 4
WHEN sqlc.narg('search_pattern') = ANY(mi.tags) THEN 4
ELSE 5
END,
mi.title ASC
@@ -385,23 +385,39 @@ JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND (
word_similarity(sqlc.narg('search_query'), mi.title) > 0.3 OR
word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3 OR
word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3 OR
word_similarity(sqlc.narg('search_query'), COALESCE(mi.tags, '')) > 0.3 OR
word_similarity(sqlc.narg('search_query'), COALESCE(mi.contributors, '')) > 0.3
)
ORDER BY
GREATEST(
word_similarity(sqlc.narg('search_query'), mi.title),
word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')),
word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')),
word_similarity(sqlc.narg('search_query'), COALESCE(mi.tags, '')),
word_similarity(sqlc.narg('search_query'), COALESCE(mi.contributors, ''))
) DESC,
mi.title ASC
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
AND (
word_similarity(sqlc.narg('search_query'), mi.title) > 0.3 OR
word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3 OR
word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3 OR
EXISTS (
SELECT 1 FROM unnest(mi.tags) AS tag
WHERE word_similarity(sqlc.narg('search_query'), tag) > 0.3
LIMIT 1
) OR
EXISTS (
SELECT 1 FROM unnest(mi.contributors) AS contributor
WHERE word_similarity(sqlc.narg('search_query'), contributor) > 0.3
LIMIT 1
)
)
ORDER BY
GREATEST(
word_similarity(sqlc.narg('search_query'), mi.title),
word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')),
word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')),
COALESCE(
(SELECT MAX(word_similarity(sqlc.narg('search_query'), tag))
FROM unnest(mi.tags) AS tag),
0
),
COALESCE(
(SELECT MAX(word_similarity(sqlc.narg('search_query'), contributor))
FROM unnest(mi.contributors) AS contributor),
0
)
) DESC,
mi.title ASC
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
-- Media Notes queries
-- name: CreateMediaNote :one
+2 -2
View File
@@ -676,8 +676,8 @@ func (h *CollectionHandler) evaluateRule(item database.ListMediaItemsRow, field,
itemValue = fmt.Sprintf("%d", item.CopyrightYear.Int32)
}
case "tags":
if item.Tags.Valid {
itemValue = item.Tags.String
if len(item.Tags) > 0 {
itemValue = strings.Join(item.Tags, ", ")
}
}
+30 -25
View File
@@ -3,6 +3,7 @@ package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/services"
"bookhoard/internal/utils"
"io"
"mime"
"net/http"
@@ -29,27 +30,27 @@ type CreateMediaItemRequest struct {
CoverImagePath string `json:"cover_image_path"`
Series string `json:"series"`
SeriesNumber int32 `json:"series_number"`
Tags string `json:"tags"`
Tags []string `json:"tags"`
ASIN string `json:"asin"`
DatePublished string `json:"date_published"`
Publisher string `json:"publisher"`
Contributors string `json:"contributors"`
Contributors []string `json:"contributors"`
}
// UpdateMediaItemRequest represents the request for updating a media item
type UpdateMediaItemRequest struct {
Title string `json:"title" validate:"required,min=1,max=500"`
Author string `json:"author"`
ISBN string `json:"isbn"`
Description string `json:"description"`
CoverImagePath string `json:"cover_image_path"`
Series string `json:"series"`
SeriesNumber int32 `json:"series_number"`
Tags string `json:"tags"`
ASIN string `json:"asin"`
DatePublished string `json:"date_published"`
Publisher string `json:"publisher"`
Contributors string `json:"contributors"`
Title string `json:"title" validate:"required,min=1,max=500"`
Author string `json:"author"`
ISBN string `json:"isbn"`
Description string `json:"description"`
CoverImagePath string `json:"cover_image_path"`
Series string `json:"series"`
SeriesNumber int32 `json:"series_number"`
Tags []string `json:"tags"`
ASIN string `json:"asin"`
DatePublished string `json:"date_published"`
Publisher string `json:"publisher"`
Contributors []string `json:"contributors"`
}
// CreateMediaNoteRequest represents the request for creating a media note
@@ -419,11 +420,11 @@ func (h *MediaHandler) HandleBulkUpdate(c echo.Context) error {
Updates []struct {
BookID string `json:"book_id" validate:"required"`
Updates struct {
Title *string `json:"title,omitempty"`
Author *string `json:"author,omitempty"`
Genre *string `json:"genre,omitempty"`
Language *string `json:"language,omitempty"`
Tags *string `json:"tags,omitempty"`
Title *string `json:"title,omitempty"`
Author *string `json:"author,omitempty"`
Genre *string `json:"genre,omitempty"`
Language *string `json:"language,omitempty"`
Tags []string `json:"tags,omitempty"`
} `json:"updates"`
} `json:"updates" validate:"required"`
}
@@ -500,8 +501,8 @@ func (h *MediaHandler) HandleBulkUpdate(c echo.Context) error {
if update.Updates.Language != nil {
updateParams.Language = pgtype.Text{String: *update.Updates.Language, Valid: true}
}
if update.Updates.Tags != nil {
updateParams.Tags = pgtype.Text{String: *update.Updates.Tags, Valid: true}
if update.Updates.Tags != nil && len(update.Updates.Tags) > 0 {
updateParams.Tags = update.Updates.Tags
}
_, err = h.db.UpdateMediaItem(c.Request().Context(), updateParams)
@@ -888,6 +889,10 @@ func (mh *MediaHandler) CreateMediaItem(c echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
if len(req.Tags) > 0 {
req.Tags = utils.NormalizeTags(req.Tags)
}
_, err := mh.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: req.LibraryID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
@@ -908,11 +913,11 @@ func (mh *MediaHandler) CreateMediaItem(c echo.Context) error {
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
Tags: pgtype.Text{String: req.Tags, Valid: req.Tags != ""},
Tags: req.Tags,
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
Contributors: pgtype.Text{String: req.Contributors, Valid: req.Contributors != ""},
Contributors: req.Contributors,
AddedByAdminID: user.ID,
})
if err != nil {
@@ -958,11 +963,11 @@ func (mh *MediaHandler) UpdateMediaItem(c echo.Context) error {
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
Tags: pgtype.Text{String: req.Tags, Valid: req.Tags != ""},
Tags: req.Tags,
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
Contributors: pgtype.Text{String: req.Contributors, Valid: req.Contributors != ""},
Contributors: req.Contributors,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
+4 -2
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
@@ -264,8 +265,9 @@ func (s *CollectionService) EvaluateRules(mediaItem database.ListMediaItemsRow,
eval.Matches = s.evaluateRule(yearStr, rule.Operator, rule.Value)
}
case "tags":
if mediaItem.Tags.Valid {
eval.Matches = s.evaluateRule(mediaItem.Tags.String, rule.Operator, rule.Value)
if len(mediaItem.Tags) > 0 {
tagsStr := strings.Join(mediaItem.Tags, ", ")
eval.Matches = s.evaluateRule(tagsStr, rule.Operator, rule.Value)
}
}
+8 -6
View File
@@ -41,11 +41,11 @@ type EbookMetadata struct {
SeriesNumber int32
Publisher string
PublishDate time.Time
Contributors string
Contributors []string
CoverPath string
ISBN string
ASIN string
Tags string
Tags []string
Phase1HashInfo *HashInfo
Phase1FormatFormats []*FormatInfo
@@ -468,8 +468,8 @@ func (s *EbookScanner) processEbookFile(ctx context.Context, path string) error
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
Contributors: pgtype.Text{String: metadata.Contributors, Valid: metadata.Contributors != ""},
Tags: pgtype.Text{String: metadata.Tags, Valid: metadata.Tags != ""},
Contributors: metadata.Contributors,
Tags: metadata.Tags,
AddedByAdminID: s.adminID,
})
if err != nil {
@@ -576,7 +576,8 @@ func (s *EbookScanner) extractEPUBMetadata(path string) (*EbookMetadata, error)
// Contributors
if contributors, err := book.MetadataByKey("contributor"); err == nil && len(contributors) > 0 {
metadata.Contributors = strings.Join(contributors, ", ")
// Already a []string from xml parsing, just assign
metadata.Contributors = contributors
}
// ISBN
@@ -602,7 +603,8 @@ func (s *EbookScanner) extractEPUBMetadata(path string) (*EbookMetadata, error)
// Tags
if tags, err := book.MetadataByKey("subject"); err == nil && len(tags) > 0 {
metadata.Tags = strings.Join(tags, ", ")
// Already a []string from xml parsing, just assign
metadata.Tags = tags
}
return metadata, nil
+52
View File
@@ -0,0 +1,52 @@
package utils
import (
"strings"
)
// NormalizeTags normalizes an array of tags by:
// 1. Converting to lowercase
// 2. Trimming whitespace
// 3. Removing duplicates
// 4. Removing empty strings
func NormalizeTags(tags []string) []string {
seen := make(map[string]struct{})
var normalized []string
for _, tag := range tags {
// Trim whitespace
tag = strings.TrimSpace(tag)
// Skip empty tags
if tag == "" {
continue
}
// Convert to lowercase
tag = strings.ToLower(tag)
// Check for duplicates
if _, exists := seen[tag]; !exists {
seen[tag] = struct{}{}
normalized = append(normalized, tag)
}
}
return normalized
}
// JoinTags converts a string array to a comma-separated string
// Maintained for backward compatibility with external systems
func JoinTags(tags []string) string {
return strings.Join(tags, ", ")
}
// SplitTags converts a comma-separated string to a normalized array
func SplitTags(tags string) []string {
if tags == "" {
return []string{}
}
parts := strings.Split(tags, ",")
return NormalizeTags(parts)
}