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:
+2
-123
@@ -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")
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user