Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03cb4c7869 | ||
|
|
8004cb81a5 | ||
|
|
77990d0dc0 | ||
|
|
0c39e04e4a | ||
|
|
8599e5c250 | ||
|
|
830741cd65 | ||
|
|
48af5d3e14 | ||
|
|
5584bdefb5 | ||
|
|
60a94df8e1 | ||
|
|
9b171a0060 | ||
|
|
b7a9b470a7 | ||
|
|
f5d9578375 |
@@ -104,6 +104,7 @@ func main() {
|
|||||||
deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries)
|
deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries)
|
||||||
deviceAuthMiddleware.SetSettings(registry)
|
deviceAuthMiddleware.SetSettings(registry)
|
||||||
processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries)
|
processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries)
|
||||||
|
hashConflictsHandler := handlers.NewHashConflictsHandler(queries)
|
||||||
|
|
||||||
// Create WebSocket connection manager
|
// Create WebSocket connection manager
|
||||||
connManager := sync.NewConnectionManager()
|
connManager := sync.NewConnectionManager()
|
||||||
@@ -202,6 +203,7 @@ func main() {
|
|||||||
MediaHandler: mediaHandler,
|
MediaHandler: mediaHandler,
|
||||||
MatchingHandler: matchingHandler,
|
MatchingHandler: matchingHandler,
|
||||||
ProcessingIssuesHandler: processingIssuesHandler,
|
ProcessingIssuesHandler: processingIssuesHandler,
|
||||||
|
HashConflictsHandler: hashConflictsHandler,
|
||||||
KOReaderHandler: koreaderHandler,
|
KOReaderHandler: koreaderHandler,
|
||||||
WSHandler: wsHandler,
|
WSHandler: wsHandler,
|
||||||
ConflictHandler: conflictHandler,
|
ConflictHandler: conflictHandler,
|
||||||
|
|||||||
@@ -1395,3 +1395,170 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_media_bookmarks_dedup
|
|||||||
CREATE INDEX IF NOT EXISTS idx_media_highlights_deleted_at ON media_highlights(deleted_at) WHERE deleted = TRUE;
|
CREATE INDEX IF NOT EXISTS idx_media_highlights_deleted_at ON media_highlights(deleted_at) WHERE deleted = TRUE;
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_notes_deleted_at ON media_notes(deleted_at) WHERE deleted = TRUE;
|
CREATE INDEX IF NOT EXISTS idx_media_notes_deleted_at ON media_notes(deleted_at) WHERE deleted = TRUE;
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_deleted_at ON media_bookmarks(deleted_at) WHERE deleted = TRUE;
|
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_deleted_at ON media_bookmarks(deleted_at) WHERE deleted = TRUE;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
--: MEDIA ITEM DEDUPLICATION + PATH UNIQUENESS
|
||||||
|
-- ============================================
|
||||||
|
-- A read-then-write race in the scanner historically allowed the same
|
||||||
|
-- (library_id, file_path) to be inserted twice. This block is self-healing:
|
||||||
|
-- it collapses any existing path-duplicates (re-parenting child rows onto a
|
||||||
|
-- survivor so no reading history is lost), then enforces uniqueness going
|
||||||
|
-- forward. Idempotent — safe to re-run on every startup.
|
||||||
|
|
||||||
|
-- Move every child row that points at p_source so it points at p_target,
|
||||||
|
-- deleting source rows that would violate a UNIQUE constraint on the target.
|
||||||
|
CREATE OR REPLACE FUNCTION reparent_media_item_children(p_target UUID, p_source UUID)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF p_target IS NULL OR p_source IS NULL OR p_target = p_source THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
DELETE FROM reading_progress
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND user_id IN (SELECT user_id FROM reading_progress WHERE media_item_id = p_target);
|
||||||
|
UPDATE reading_progress SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM reading_speed
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND user_id IN (SELECT user_id FROM reading_speed WHERE media_item_id = p_target);
|
||||||
|
UPDATE reading_speed SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM media_ratings
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND user_id IN (SELECT user_id FROM media_ratings WHERE media_item_id = p_target);
|
||||||
|
UPDATE media_ratings SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM media_bookmarks
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND (user_id, title) IN (SELECT user_id, title FROM media_bookmarks WHERE media_item_id = p_target);
|
||||||
|
UPDATE media_bookmarks SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM media_item_formats
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND format_type IN (SELECT format_type FROM media_item_formats WHERE media_item_id = p_target);
|
||||||
|
UPDATE media_item_formats SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM collection_items
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND collection_id IN (SELECT collection_id FROM collection_items WHERE media_item_id = p_target);
|
||||||
|
UPDATE collection_items SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM kobo_shelves
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND device_id IN (SELECT device_id FROM kobo_shelves WHERE media_item_id = p_target);
|
||||||
|
UPDATE kobo_shelves SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM panel_data
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND page_number IN (SELECT page_number FROM panel_data WHERE media_item_id = p_target);
|
||||||
|
UPDATE panel_data SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM processing_issues
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND issue_type IN (SELECT issue_type FROM processing_issues WHERE media_item_id = p_target);
|
||||||
|
UPDATE processing_issues SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM device_file_aliases
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND (device_id, file_path) IN (SELECT device_id, file_path FROM device_file_aliases WHERE media_item_id = p_target);
|
||||||
|
UPDATE device_file_aliases SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
-- Tables whose UNIQUE keys do not include media_item_id.
|
||||||
|
UPDATE device_catalogs SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
UPDATE kobo_entitlements SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
UPDATE media_highlights SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
UPDATE media_notes SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
UPDATE reading_history SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
UPDATE sync_conflicts SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
UPDATE sync_queue SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Collapse every (library_id, file_path) group into a single row.
|
||||||
|
-- Survivor = the row with the most user data; ties broken by lowest id.
|
||||||
|
CREATE OR REPLACE FUNCTION dedup_media_items_by_path() RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
g RECORD;
|
||||||
|
v_surv UUID;
|
||||||
|
v_loser UUID;
|
||||||
|
BEGIN
|
||||||
|
FOR g IN
|
||||||
|
SELECT library_id, file_path
|
||||||
|
FROM media_items
|
||||||
|
GROUP BY library_id, file_path
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
LOOP
|
||||||
|
SELECT mi.id INTO v_surv
|
||||||
|
FROM media_items mi
|
||||||
|
WHERE mi.library_id = g.library_id AND mi.file_path = g.file_path
|
||||||
|
ORDER BY
|
||||||
|
((SELECT COUNT(*) FROM reading_progress rp WHERE rp.media_item_id = mi.id)
|
||||||
|
+ (SELECT COUNT(*) FROM media_highlights mh WHERE mh.media_item_id = mi.id)
|
||||||
|
+ (SELECT COUNT(*) FROM media_bookmarks mb WHERE mb.media_item_id = mi.id)
|
||||||
|
+ (SELECT COUNT(*) FROM media_notes mn WHERE mn.media_item_id = mi.id)
|
||||||
|
+ (SELECT COUNT(*) FROM reading_history rh WHERE rh.media_item_id = mi.id)
|
||||||
|
+ (SELECT COUNT(*) FROM collection_items ci WHERE ci.media_item_id = mi.id)) DESC,
|
||||||
|
mi.id ASC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
FOR v_loser IN
|
||||||
|
SELECT id FROM media_items
|
||||||
|
WHERE library_id = g.library_id AND file_path = g.file_path AND id <> v_surv
|
||||||
|
ORDER BY id
|
||||||
|
LOOP
|
||||||
|
PERFORM reparent_media_item_children(v_surv, v_loser);
|
||||||
|
DELETE FROM media_items WHERE id = v_loser;
|
||||||
|
END LOOP;
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Collapse any existing path-duplicates so the constraint below can be created.
|
||||||
|
SELECT dedup_media_items_by_path();
|
||||||
|
|
||||||
|
-- Enforce path uniqueness going forward (guarded so re-runs don't error).
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'media_items_library_id_file_path_key'
|
||||||
|
AND conrelid = 'media_items'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE media_items
|
||||||
|
ADD CONSTRAINT media_items_library_id_file_path_key UNIQUE (library_id, file_path);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
--: HASH CONFLICTS
|
||||||
|
-- ============================================
|
||||||
|
-- Records content-duplicate groups discovered during hash backfill or rescan:
|
||||||
|
-- two or more media_items in the same library share a file_sha256 but live at
|
||||||
|
-- different file paths (e.g. the same book imported twice under two names on
|
||||||
|
-- a preexisting database). Unlike path duplicates these cannot be auto-collapsed
|
||||||
|
-- (keeping both copies may be intentional), so each group is surfaced on the
|
||||||
|
-- admin Hash Conflicts page for the user to resolve:
|
||||||
|
-- keep_all - both copies are intentional; just stop flagging
|
||||||
|
-- kept:<uuid> - merge every other copy's child rows into the kept item
|
||||||
|
-- (via reparent_media_item_children) and delete the losers
|
||||||
|
CREATE TABLE IF NOT EXISTS hash_conflicts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||||
|
file_sha256 CHAR(64) NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','resolved')),
|
||||||
|
resolution VARCHAR(50), -- 'keep_all' or 'kept:<media_item_uuid>' (41 chars)
|
||||||
|
resolved_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
resolved_at TIMESTAMPTZ,
|
||||||
|
UNIQUE(library_id, file_sha256)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_hash_conflicts_status ON hash_conflicts(status);
|
||||||
|
|
||||||
|
-- Widen for databases created before the resolution format settled (no-op otherwise)
|
||||||
|
ALTER TABLE hash_conflicts ALTER COLUMN resolution TYPE VARCHAR(50);
|
||||||
|
|||||||
@@ -17,20 +17,29 @@ KOReader uses a custom JSON-based sync protocol.
|
|||||||
|
|
||||||
### Request Body
|
### Request Body
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
| ------------------ | ------- | -------- | ----------------------------- |
|
| ------------------ | ------- | -------- | ---------------------------------------------------- |
|
||||||
| library_id | string | No | Library UUID |
|
| library_id | string | No | Library UUID |
|
||||||
| books | array | Yes | Array of book sync data |
|
| books | array | Yes | Array of book sync data |
|
||||||
| books[].uuid | string | Yes | Book UUID |
|
| books[].uuid | string | No\* | Book UUID (highest-confidence match; omitted on first sync of a newly downloaded book) |
|
||||||
| books[].title | string | Yes | Book title |
|
| books[].sha256 | string | No\* | Full-file SHA-256 (64 hex chars); used to resolve the book when `uuid` is absent |
|
||||||
| books[].authors | array | Yes | Array of author names |
|
| books[].file_path | string | No | Device-local file path; used to create/look up a device file alias |
|
||||||
| books[].progress | float | Yes | Progress percentage (0-1) |
|
| books[].title | string | Yes | Book title |
|
||||||
| books[].percentage | float | Yes | Progress percentage (0-1) |
|
| books[].authors | array | Yes | Array of author names |
|
||||||
| books[].last_read | string | Yes | ISO 8601 timestamp |
|
| books[].progress | float | Yes | Progress percentage (0-1) |
|
||||||
| books[].chapter | integer | No | Current chapter |
|
| books[].percentage | float | Yes | Progress percentage (0-1) |
|
||||||
| books[].epubcfi | string | No | EPUB CFI location |
|
| books[].last_read | string | Yes | ISO 8601 timestamp |
|
||||||
| books[].character | integer | No | Character offset |
|
| books[].chapter | integer | No | Current chapter |
|
||||||
| books[].bookmarks | array | No | Array of bookmarks/highlights |
|
| books[].epubcfi | string | No | EPUB CFI location |
|
||||||
|
| books[].character | integer | No | Character offset |
|
||||||
|
| books[].bookmarks | array | No | Array of bookmarks/highlights |
|
||||||
|
|
||||||
|
\* At least one of `uuid` or `sha256` should be present. The server resolves the
|
||||||
|
book through the shared `BookResolver` with this priority: `uuid` → `sha256` →
|
||||||
|
`file_path` alias → `title`/`author`. SHA-256 matching is **format-aware**: it
|
||||||
|
checks `media_items.file_sha256` first, then `media_item_formats.file_sha256`, so
|
||||||
|
a converted file (e.g. KEPUB or PDF) downloaded via OPDS matches even though its
|
||||||
|
hash differs from the primary format's hash.
|
||||||
|
|
||||||
### Example Request
|
### Example Request
|
||||||
|
|
||||||
@@ -102,6 +111,7 @@ Authorization: Bearer device-token
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"uuid": "book-uuid",
|
"uuid": "book-uuid",
|
||||||
|
"sha256": "ff3e4501bf9d72dea2ae28731a6cb5b83d7a7532c05b5d2dd083d0dbc9193ebf",
|
||||||
"title": "Book Title",
|
"title": "Book Title",
|
||||||
"authors": ["Author Name"],
|
"authors": ["Author Name"],
|
||||||
"progress": {
|
"progress": {
|
||||||
@@ -119,3 +129,18 @@ Authorization: Bearer device-token
|
|||||||
"last_sync": "2026-01-30T20:00:00Z"
|
"last_sync": "2026-01-30T20:00:00Z"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`sha256` is the canonical primary-format hash of the book on the server. It is
|
||||||
|
returned so clients can cache it regardless of how the book was originally
|
||||||
|
obtained. The library list endpoint (`GET /api/sync/koreader/library`) includes
|
||||||
|
the same `sha256` field on each book.
|
||||||
|
|
||||||
|
## Book identification
|
||||||
|
|
||||||
|
Every client/sync interface (KOReader, Kobo, OPDS, the device-link UI, and any
|
||||||
|
future mobile app) resolves books through a single shared service:
|
||||||
|
[`internal/services/book_resolver.go`](../../../internal/services/book_resolver.go).
|
||||||
|
The import-time SHA-256 (stored on `media_items.file_sha256`, plus a per-format
|
||||||
|
hash on `media_item_formats.file_sha256` for KEPUB/PDF) is the canonical shared
|
||||||
|
identifier. New clients should resolve by SHA-256 via `BookResolver` rather than
|
||||||
|
re-implementing their own matcher.
|
||||||
|
|||||||
@@ -92,6 +92,17 @@ type DictionaryCache struct {
|
|||||||
AccessedAt pgtype.Timestamptz `db:"accessed_at" json:"accessed_at"`
|
AccessedAt pgtype.Timestamptz `db:"accessed_at" json:"accessed_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HashConflicts struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||||
|
FileSha256 string `db:"file_sha256" json:"file_sha256"`
|
||||||
|
Status string `db:"status" json:"status"`
|
||||||
|
Resolution pgtype.Text `db:"resolution" json:"resolution"`
|
||||||
|
ResolvedBy pgtype.UUID `db:"resolved_by" json:"resolved_by"`
|
||||||
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
|
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type KoboEntitlements struct {
|
type KoboEntitlements struct {
|
||||||
ID pgtype.UUID `db:"id" json:"id"`
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
||||||
@@ -537,4 +548,3 @@ type Users struct {
|
|||||||
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
||||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ type Querier interface {
|
|||||||
// Create device shelf mapping
|
// Create device shelf mapping
|
||||||
CreateDeviceShelfMapping(ctx context.Context, arg CreateDeviceShelfMappingParams) (DeviceShelfMappings, error)
|
CreateDeviceShelfMapping(ctx context.Context, arg CreateDeviceShelfMappingParams) (DeviceShelfMappings, error)
|
||||||
CreateDictionaryEntry(ctx context.Context, arg CreateDictionaryEntryParams) (DictionaryCache, error)
|
CreateDictionaryEntry(ctx context.Context, arg CreateDictionaryEntryParams) (DictionaryCache, error)
|
||||||
|
// HASH CONFLICTS QUERIES
|
||||||
|
// Record a pending hash conflict (no-op if the group is already tracked, so
|
||||||
|
// resolved groups stay resolved and are never re-flagged)
|
||||||
|
CreateHashConflict(ctx context.Context, arg CreateHashConflictParams) error
|
||||||
// Libraries queries
|
// Libraries queries
|
||||||
CreateLibrary(ctx context.Context, arg CreateLibraryParams) (Libraries, error)
|
CreateLibrary(ctx context.Context, arg CreateLibraryParams) (Libraries, error)
|
||||||
CreateMediaBookmark(ctx context.Context, arg CreateMediaBookmarkParams) (MediaBookmarks, error)
|
CreateMediaBookmark(ctx context.Context, arg CreateMediaBookmarkParams) (MediaBookmarks, error)
|
||||||
@@ -129,6 +133,8 @@ type Querier interface {
|
|||||||
DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
||||||
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
||||||
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error
|
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error
|
||||||
|
// Find content-duplicate groups (same library + SHA-256, more than one row)
|
||||||
|
FindHashConflictGroups(ctx context.Context) ([]FindHashConflictGroupsRow, error)
|
||||||
GenerateKoboEntitlementId(ctx context.Context) (interface{}, error)
|
GenerateKoboEntitlementId(ctx context.Context) (interface{}, error)
|
||||||
// ============================================
|
// ============================================
|
||||||
// ANNOTATION SERVE QUERIES
|
// ANNOTATION SERVE QUERIES
|
||||||
@@ -184,6 +190,7 @@ type Querier interface {
|
|||||||
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
|
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
|
||||||
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
|
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
|
||||||
GetFirstAdminExclude(ctx context.Context, id pgtype.UUID) (GetFirstAdminExcludeRow, error)
|
GetFirstAdminExclude(ctx context.Context, id pgtype.UUID) (GetFirstAdminExcludeRow, error)
|
||||||
|
GetHashConflict(ctx context.Context, id pgtype.UUID) (HashConflicts, error)
|
||||||
GetKoboEntitlementByContentId(ctx context.Context, arg GetKoboEntitlementByContentIdParams) (GetKoboEntitlementByContentIdRow, error)
|
GetKoboEntitlementByContentId(ctx context.Context, arg GetKoboEntitlementByContentIdParams) (GetKoboEntitlementByContentIdRow, error)
|
||||||
GetKoboEntitlementByEntitlementId(ctx context.Context, arg GetKoboEntitlementByEntitlementIdParams) (GetKoboEntitlementByEntitlementIdRow, error)
|
GetKoboEntitlementByEntitlementId(ctx context.Context, arg GetKoboEntitlementByEntitlementIdParams) (GetKoboEntitlementByEntitlementIdRow, error)
|
||||||
GetKoboEntitlementsForDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetKoboEntitlementsForDeviceRow, error)
|
GetKoboEntitlementsForDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetKoboEntitlementsForDeviceRow, error)
|
||||||
@@ -232,12 +239,16 @@ type Querier interface {
|
|||||||
GetMediaItemByOPFUUID(ctx context.Context, opfUuid pgtype.Text) (MediaItems, error)
|
GetMediaItemByOPFUUID(ctx context.Context, opfUuid pgtype.Text) (MediaItems, error)
|
||||||
// Get media item by SHA-256 hash
|
// Get media item by SHA-256 hash
|
||||||
GetMediaItemBySHA256(ctx context.Context, fileSha256 pgtype.Text) (MediaItems, error)
|
GetMediaItemBySHA256(ctx context.Context, fileSha256 pgtype.Text) (MediaItems, error)
|
||||||
|
// Get media item by SHA-256 hash within a specific library (content dedup)
|
||||||
|
GetMediaItemBySHA256AndLibrary(ctx context.Context, arg GetMediaItemBySHA256AndLibraryParams) (MediaItems, error)
|
||||||
// Get media item format by SHA-256
|
// Get media item format by SHA-256
|
||||||
GetMediaItemFormatBySHA256(ctx context.Context, fileSha256 pgtype.Text) (MediaItemFormats, error)
|
GetMediaItemFormatBySHA256(ctx context.Context, fileSha256 pgtype.Text) (MediaItemFormats, error)
|
||||||
// Get media item format by type
|
// Get media item format by type
|
||||||
GetMediaItemFormatByType(ctx context.Context, arg GetMediaItemFormatByTypeParams) (MediaItemFormats, error)
|
GetMediaItemFormatByType(ctx context.Context, arg GetMediaItemFormatByTypeParams) (MediaItemFormats, error)
|
||||||
// Get media item formats
|
// Get media item formats
|
||||||
GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UUID) ([]MediaItemFormats, error)
|
GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UUID) ([]MediaItemFormats, error)
|
||||||
|
// Per-item user-data counts, used when choosing which duplicate copy to keep
|
||||||
|
GetMediaItemUsageCounts(ctx context.Context, mediaItemID pgtype.UUID) (GetMediaItemUsageCountsRow, error)
|
||||||
GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, error)
|
GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, error)
|
||||||
// ============================================
|
// ============================================
|
||||||
// ANNOTATION SYNC QUERIES (notes)
|
// ANNOTATION SYNC QUERIES (notes)
|
||||||
@@ -321,7 +332,12 @@ type Querier interface {
|
|||||||
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
|
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
|
||||||
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
|
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
|
||||||
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
|
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
|
||||||
|
// List all media items sharing a SHA-256 hash within a library (hash conflict group)
|
||||||
|
ListMediaItemsBySHA256AndLibrary(ctx context.Context, arg ListMediaItemsBySHA256AndLibraryParams) ([]MediaItems, error)
|
||||||
|
// List media items that have no stored SHA-256 (imported before hashing existed)
|
||||||
|
ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems, error)
|
||||||
ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSortedParams) ([]ListMediaItemsSortedRow, error)
|
ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSortedParams) ([]ListMediaItemsSortedRow, error)
|
||||||
|
ListPendingHashConflicts(ctx context.Context) ([]ListPendingHashConflictsRow, error)
|
||||||
ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error)
|
ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error)
|
||||||
ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error)
|
ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error)
|
||||||
ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error)
|
ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error)
|
||||||
@@ -339,7 +355,10 @@ type Querier interface {
|
|||||||
// Remove book from collection
|
// Remove book from collection
|
||||||
RemoveBookFromCollection(ctx context.Context, arg RemoveBookFromCollectionParams) error
|
RemoveBookFromCollection(ctx context.Context, arg RemoveBookFromCollectionParams) error
|
||||||
RemoveBookFromKoboShelf(ctx context.Context, arg RemoveBookFromKoboShelfParams) error
|
RemoveBookFromKoboShelf(ctx context.Context, arg RemoveBookFromKoboShelfParams) error
|
||||||
|
// Re-parent all child rows of p_source onto p_target (defined in schema.sql)
|
||||||
|
ReparentMediaItemChildren(ctx context.Context, arg ReparentMediaItemChildrenParams) error
|
||||||
ResetSystemCollectionMetadata(ctx context.Context, arg ResetSystemCollectionMetadataParams) error
|
ResetSystemCollectionMetadata(ctx context.Context, arg ResetSystemCollectionMetadataParams) error
|
||||||
|
ResolveHashConflict(ctx context.Context, arg ResolveHashConflictParams) error
|
||||||
ResolveProcessingIssue(ctx context.Context, arg ResolveProcessingIssueParams) (ProcessingIssues, error)
|
ResolveProcessingIssue(ctx context.Context, arg ResolveProcessingIssueParams) (ProcessingIssues, error)
|
||||||
ResolveSyncConflict(ctx context.Context, arg ResolveSyncConflictParams) (SyncConflicts, error)
|
ResolveSyncConflict(ctx context.Context, arg ResolveSyncConflictParams) (SyncConflicts, error)
|
||||||
// Resolve unlinked book
|
// Resolve unlinked book
|
||||||
|
|||||||
@@ -567,6 +567,26 @@ func (q *Queries) CreateDictionaryEntry(ctx context.Context, arg CreateDictionar
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CreateHashConflict = `-- name: CreateHashConflict :exec
|
||||||
|
|
||||||
|
INSERT INTO hash_conflicts (library_id, file_sha256)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (library_id, file_sha256) DO NOTHING
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateHashConflictParams struct {
|
||||||
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||||
|
FileSha256 string `db:"file_sha256" json:"file_sha256"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HASH CONFLICTS QUERIES
|
||||||
|
// Record a pending hash conflict (no-op if the group is already tracked, so
|
||||||
|
// resolved groups stay resolved and are never re-flagged)
|
||||||
|
func (q *Queries) CreateHashConflict(ctx context.Context, arg CreateHashConflictParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, CreateHashConflict, arg.LibraryID, arg.FileSha256)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const CreateLibrary = `-- name: CreateLibrary :one
|
const CreateLibrary = `-- name: CreateLibrary :one
|
||||||
INSERT INTO libraries (name, description, library_type_id, created_by_admin_id)
|
INSERT INTO libraries (name, description, library_type_id, created_by_admin_id)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
@@ -875,6 +895,7 @@ func (q *Queries) CreateMediaHighlightFull(ctx context.Context, arg CreateMediaH
|
|||||||
const CreateMediaItem = `-- name: CreateMediaItem :one
|
const CreateMediaItem = `-- name: CreateMediaItem :one
|
||||||
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name)
|
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44)
|
||||||
|
ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW()
|
||||||
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
|
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -1044,6 +1065,11 @@ const CreateMediaItemFormat = `-- name: CreateMediaItemFormat :one
|
|||||||
|
|
||||||
INSERT INTO media_item_formats (media_item_id, format_type, file_path, file_sha256, file_size_bytes, mime_type, converted_from_format_id)
|
INSERT INTO media_item_formats (media_item_id, format_type, file_path, file_sha256, file_size_bytes, mime_type, converted_from_format_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
ON CONFLICT (media_item_id, format_type) DO UPDATE SET
|
||||||
|
file_path = EXCLUDED.file_path,
|
||||||
|
file_sha256 = EXCLUDED.file_sha256,
|
||||||
|
file_size_bytes = EXCLUDED.file_size_bytes,
|
||||||
|
mime_type = EXCLUDED.mime_type
|
||||||
RETURNING id, media_item_id, format_type, file_path, file_sha256, file_size_bytes, mime_type, created_at, converted_from_format_id
|
RETURNING id, media_item_id, format_type, file_path, file_sha256, file_size_bytes, mime_type, created_at, converted_from_format_id
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -2079,6 +2105,41 @@ func (q *Queries) DeleteUserSystemCollection(ctx context.Context, arg DeleteUser
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FindHashConflictGroups = `-- name: FindHashConflictGroups :many
|
||||||
|
SELECT library_id, file_sha256, COUNT(*) AS dup_count
|
||||||
|
FROM media_items
|
||||||
|
WHERE file_sha256 IS NOT NULL
|
||||||
|
GROUP BY library_id, file_sha256
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
`
|
||||||
|
|
||||||
|
type FindHashConflictGroupsRow struct {
|
||||||
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||||
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
||||||
|
DupCount int64 `db:"dup_count" json:"dup_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find content-duplicate groups (same library + SHA-256, more than one row)
|
||||||
|
func (q *Queries) FindHashConflictGroups(ctx context.Context) ([]FindHashConflictGroupsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, FindHashConflictGroups)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []FindHashConflictGroupsRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i FindHashConflictGroupsRow
|
||||||
|
if err := rows.Scan(&i.LibraryID, &i.FileSha256, &i.DupCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const GenerateKoboEntitlementId = `-- name: GenerateKoboEntitlementId :one
|
const GenerateKoboEntitlementId = `-- name: GenerateKoboEntitlementId :one
|
||||||
SELECT 'kobo_' || uuid_generate_v4()::TEXT as entitlement_id
|
SELECT 'kobo_' || uuid_generate_v4()::TEXT as entitlement_id
|
||||||
`
|
`
|
||||||
@@ -3705,6 +3766,26 @@ func (q *Queries) GetFirstAdminExclude(ctx context.Context, id pgtype.UUID) (Get
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GetHashConflict = `-- name: GetHashConflict :one
|
||||||
|
SELECT id, library_id, file_sha256, status, resolution, resolved_by, created_at, resolved_at FROM hash_conflicts WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetHashConflict(ctx context.Context, id pgtype.UUID) (HashConflicts, error) {
|
||||||
|
row := q.db.QueryRow(ctx, GetHashConflict, id)
|
||||||
|
var i HashConflicts
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.LibraryID,
|
||||||
|
&i.FileSha256,
|
||||||
|
&i.Status,
|
||||||
|
&i.Resolution,
|
||||||
|
&i.ResolvedBy,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.ResolvedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const GetKoboEntitlementByContentId = `-- name: GetKoboEntitlementByContentId :one
|
const GetKoboEntitlementByContentId = `-- name: GetKoboEntitlementByContentId :one
|
||||||
SELECT ke.id, ke.device_id, ke.media_item_id, ke.entitlement_id, ke.content_id, ke.revision_number, ke.purchase_date, ke.accession_date, ke.book_status, ke.sync_status, ke.kobo_metadata, ke.created_at, ke.updated_at, mi.title, mi.author, mi.file_path
|
SELECT ke.id, ke.device_id, ke.media_item_id, ke.entitlement_id, ke.content_id, ke.revision_number, ke.purchase_date, ke.accession_date, ke.book_status, ke.sync_status, ke.kobo_metadata, ke.created_at, ke.updated_at, mi.title, mi.author, mi.file_path
|
||||||
FROM kobo_entitlements ke
|
FROM kobo_entitlements ke
|
||||||
@@ -5312,6 +5393,85 @@ func (q *Queries) GetMediaItemBySHA256(ctx context.Context, fileSha256 pgtype.Te
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GetMediaItemBySHA256AndLibrary = `-- name: GetMediaItemBySHA256AndLibrary :one
|
||||||
|
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 = $1 AND library_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetMediaItemBySHA256AndLibraryParams struct {
|
||||||
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
||||||
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get media item by SHA-256 hash within a specific library (content dedup)
|
||||||
|
func (q *Queries) GetMediaItemBySHA256AndLibrary(ctx context.Context, arg GetMediaItemBySHA256AndLibraryParams) (MediaItems, error) {
|
||||||
|
row := q.db.QueryRow(ctx, GetMediaItemBySHA256AndLibrary, arg.FileSha256, arg.LibraryID)
|
||||||
|
var i MediaItems
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.LibraryID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Author,
|
||||||
|
&i.Isbn,
|
||||||
|
&i.Description,
|
||||||
|
&i.FilePath,
|
||||||
|
&i.FileSize,
|
||||||
|
&i.MimeType,
|
||||||
|
&i.CoverImagePath,
|
||||||
|
&i.Series,
|
||||||
|
&i.SeriesNumber,
|
||||||
|
&i.Tags,
|
||||||
|
&i.Asin,
|
||||||
|
&i.DatePublished,
|
||||||
|
&i.Publisher,
|
||||||
|
&i.Contributors,
|
||||||
|
&i.Language,
|
||||||
|
&i.Edition,
|
||||||
|
&i.PageCount,
|
||||||
|
&i.Genre,
|
||||||
|
&i.CopyrightYear,
|
||||||
|
&i.GoodreadsID,
|
||||||
|
&i.OpenlibraryID,
|
||||||
|
&i.GoogleBooksID,
|
||||||
|
&i.AddedByAdminID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.ImportedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.FormatGroup,
|
||||||
|
&i.FormatMimetype,
|
||||||
|
&i.IsReflowable,
|
||||||
|
&i.HasFixedLayout,
|
||||||
|
&i.TotalCharacters,
|
||||||
|
&i.ChapterCount,
|
||||||
|
&i.EntitlementID,
|
||||||
|
&i.RevisionNumber,
|
||||||
|
&i.KoboContentID,
|
||||||
|
&i.KoboMetadata,
|
||||||
|
&i.MangaType,
|
||||||
|
&i.ReadingDirection,
|
||||||
|
&i.SeriesCount,
|
||||||
|
&i.Volume,
|
||||||
|
&i.Imprint,
|
||||||
|
&i.AgeRating,
|
||||||
|
&i.WebUrl,
|
||||||
|
&i.StoryArc,
|
||||||
|
&i.IsBlackAndWhite,
|
||||||
|
&i.MetadataNotes,
|
||||||
|
&i.CommunityRating,
|
||||||
|
&i.AlternateInfo,
|
||||||
|
&i.ScanInformation,
|
||||||
|
&i.Summary,
|
||||||
|
&i.ChapterMetadata,
|
||||||
|
&i.LibraryTypeName,
|
||||||
|
&i.TagsSearch,
|
||||||
|
&i.ContributorsSearch,
|
||||||
|
&i.FileSha256,
|
||||||
|
&i.OpfIdentifier,
|
||||||
|
&i.OpfUuid,
|
||||||
|
&i.HashConfidence,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const GetMediaItemFormatBySHA256 = `-- name: GetMediaItemFormatBySHA256 :one
|
const GetMediaItemFormatBySHA256 = `-- name: GetMediaItemFormatBySHA256 :one
|
||||||
SELECT id, media_item_id, format_type, file_path, file_sha256, file_size_bytes, mime_type, created_at, converted_from_format_id FROM media_item_formats WHERE file_sha256 = $1
|
SELECT id, media_item_id, format_type, file_path, file_sha256, file_size_bytes, mime_type, created_at, converted_from_format_id FROM media_item_formats WHERE file_sha256 = $1
|
||||||
`
|
`
|
||||||
@@ -5396,6 +5556,37 @@ func (q *Queries) GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UU
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GetMediaItemUsageCounts = `-- name: GetMediaItemUsageCounts :one
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM reading_progress rp WHERE rp.media_item_id = $1) AS progress_count,
|
||||||
|
(SELECT COUNT(*) FROM media_highlights mh WHERE mh.media_item_id = $1) AS highlights_count,
|
||||||
|
(SELECT COUNT(*) FROM media_bookmarks mb WHERE mb.media_item_id = $1) AS bookmarks_count,
|
||||||
|
(SELECT COUNT(*) FROM media_notes mn WHERE mn.media_item_id = $1) AS notes_count,
|
||||||
|
(SELECT COUNT(*) FROM collection_items ci WHERE ci.media_item_id = $1) AS collections_count
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetMediaItemUsageCountsRow struct {
|
||||||
|
ProgressCount int64 `db:"progress_count" json:"progress_count"`
|
||||||
|
HighlightsCount int64 `db:"highlights_count" json:"highlights_count"`
|
||||||
|
BookmarksCount int64 `db:"bookmarks_count" json:"bookmarks_count"`
|
||||||
|
NotesCount int64 `db:"notes_count" json:"notes_count"`
|
||||||
|
CollectionsCount int64 `db:"collections_count" json:"collections_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-item user-data counts, used when choosing which duplicate copy to keep
|
||||||
|
func (q *Queries) GetMediaItemUsageCounts(ctx context.Context, mediaItemID pgtype.UUID) (GetMediaItemUsageCountsRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, GetMediaItemUsageCounts, mediaItemID)
|
||||||
|
var i GetMediaItemUsageCountsRow
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ProgressCount,
|
||||||
|
&i.HighlightsCount,
|
||||||
|
&i.BookmarksCount,
|
||||||
|
&i.NotesCount,
|
||||||
|
&i.CollectionsCount,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const GetMediaNote = `-- name: GetMediaNote :one
|
const GetMediaNote = `-- name: GetMediaNote :one
|
||||||
SELECT id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data, dedup_key, last_modified_at, last_modified_source, deleted, deleted_at FROM media_notes WHERE id = $1
|
SELECT id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data, dedup_key, last_modified_at, last_modified_source, deleted, deleted_at FROM media_notes WHERE id = $1
|
||||||
`
|
`
|
||||||
@@ -8365,6 +8556,185 @@ func (q *Queries) ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ListMediaItemsBySHA256AndLibrary = `-- name: ListMediaItemsBySHA256AndLibrary :many
|
||||||
|
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 = $1 AND library_id = $2 ORDER BY file_path
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListMediaItemsBySHA256AndLibraryParams struct {
|
||||||
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
||||||
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all media items sharing a SHA-256 hash within a library (hash conflict group)
|
||||||
|
func (q *Queries) ListMediaItemsBySHA256AndLibrary(ctx context.Context, arg ListMediaItemsBySHA256AndLibraryParams) ([]MediaItems, error) {
|
||||||
|
rows, err := q.db.Query(ctx, ListMediaItemsBySHA256AndLibrary, arg.FileSha256, arg.LibraryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []MediaItems{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i MediaItems
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.LibraryID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Author,
|
||||||
|
&i.Isbn,
|
||||||
|
&i.Description,
|
||||||
|
&i.FilePath,
|
||||||
|
&i.FileSize,
|
||||||
|
&i.MimeType,
|
||||||
|
&i.CoverImagePath,
|
||||||
|
&i.Series,
|
||||||
|
&i.SeriesNumber,
|
||||||
|
&i.Tags,
|
||||||
|
&i.Asin,
|
||||||
|
&i.DatePublished,
|
||||||
|
&i.Publisher,
|
||||||
|
&i.Contributors,
|
||||||
|
&i.Language,
|
||||||
|
&i.Edition,
|
||||||
|
&i.PageCount,
|
||||||
|
&i.Genre,
|
||||||
|
&i.CopyrightYear,
|
||||||
|
&i.GoodreadsID,
|
||||||
|
&i.OpenlibraryID,
|
||||||
|
&i.GoogleBooksID,
|
||||||
|
&i.AddedByAdminID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.ImportedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.FormatGroup,
|
||||||
|
&i.FormatMimetype,
|
||||||
|
&i.IsReflowable,
|
||||||
|
&i.HasFixedLayout,
|
||||||
|
&i.TotalCharacters,
|
||||||
|
&i.ChapterCount,
|
||||||
|
&i.EntitlementID,
|
||||||
|
&i.RevisionNumber,
|
||||||
|
&i.KoboContentID,
|
||||||
|
&i.KoboMetadata,
|
||||||
|
&i.MangaType,
|
||||||
|
&i.ReadingDirection,
|
||||||
|
&i.SeriesCount,
|
||||||
|
&i.Volume,
|
||||||
|
&i.Imprint,
|
||||||
|
&i.AgeRating,
|
||||||
|
&i.WebUrl,
|
||||||
|
&i.StoryArc,
|
||||||
|
&i.IsBlackAndWhite,
|
||||||
|
&i.MetadataNotes,
|
||||||
|
&i.CommunityRating,
|
||||||
|
&i.AlternateInfo,
|
||||||
|
&i.ScanInformation,
|
||||||
|
&i.Summary,
|
||||||
|
&i.ChapterMetadata,
|
||||||
|
&i.LibraryTypeName,
|
||||||
|
&i.TagsSearch,
|
||||||
|
&i.ContributorsSearch,
|
||||||
|
&i.FileSha256,
|
||||||
|
&i.OpfIdentifier,
|
||||||
|
&i.OpfUuid,
|
||||||
|
&i.HashConfidence,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListMediaItemsMissingHash = `-- name: ListMediaItemsMissingHash :many
|
||||||
|
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 IS NULL ORDER BY created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
// List media items that have no stored SHA-256 (imported before hashing existed)
|
||||||
|
func (q *Queries) ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems, error) {
|
||||||
|
rows, err := q.db.Query(ctx, ListMediaItemsMissingHash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []MediaItems{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i MediaItems
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.LibraryID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Author,
|
||||||
|
&i.Isbn,
|
||||||
|
&i.Description,
|
||||||
|
&i.FilePath,
|
||||||
|
&i.FileSize,
|
||||||
|
&i.MimeType,
|
||||||
|
&i.CoverImagePath,
|
||||||
|
&i.Series,
|
||||||
|
&i.SeriesNumber,
|
||||||
|
&i.Tags,
|
||||||
|
&i.Asin,
|
||||||
|
&i.DatePublished,
|
||||||
|
&i.Publisher,
|
||||||
|
&i.Contributors,
|
||||||
|
&i.Language,
|
||||||
|
&i.Edition,
|
||||||
|
&i.PageCount,
|
||||||
|
&i.Genre,
|
||||||
|
&i.CopyrightYear,
|
||||||
|
&i.GoodreadsID,
|
||||||
|
&i.OpenlibraryID,
|
||||||
|
&i.GoogleBooksID,
|
||||||
|
&i.AddedByAdminID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.ImportedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.FormatGroup,
|
||||||
|
&i.FormatMimetype,
|
||||||
|
&i.IsReflowable,
|
||||||
|
&i.HasFixedLayout,
|
||||||
|
&i.TotalCharacters,
|
||||||
|
&i.ChapterCount,
|
||||||
|
&i.EntitlementID,
|
||||||
|
&i.RevisionNumber,
|
||||||
|
&i.KoboContentID,
|
||||||
|
&i.KoboMetadata,
|
||||||
|
&i.MangaType,
|
||||||
|
&i.ReadingDirection,
|
||||||
|
&i.SeriesCount,
|
||||||
|
&i.Volume,
|
||||||
|
&i.Imprint,
|
||||||
|
&i.AgeRating,
|
||||||
|
&i.WebUrl,
|
||||||
|
&i.StoryArc,
|
||||||
|
&i.IsBlackAndWhite,
|
||||||
|
&i.MetadataNotes,
|
||||||
|
&i.CommunityRating,
|
||||||
|
&i.AlternateInfo,
|
||||||
|
&i.ScanInformation,
|
||||||
|
&i.Summary,
|
||||||
|
&i.ChapterMetadata,
|
||||||
|
&i.LibraryTypeName,
|
||||||
|
&i.TagsSearch,
|
||||||
|
&i.ContributorsSearch,
|
||||||
|
&i.FileSha256,
|
||||||
|
&i.OpfIdentifier,
|
||||||
|
&i.OpfUuid,
|
||||||
|
&i.HashConfidence,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const ListMediaItemsSorted = `-- name: ListMediaItemsSorted :many
|
const ListMediaItemsSorted = `-- name: ListMediaItemsSorted :many
|
||||||
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name
|
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name
|
||||||
FROM media_items mi
|
FROM media_items mi
|
||||||
@@ -8602,6 +8972,54 @@ func (q *Queries) ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSo
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ListPendingHashConflicts = `-- name: ListPendingHashConflicts :many
|
||||||
|
SELECT hc.id, hc.library_id, hc.file_sha256, hc.created_at,
|
||||||
|
l.name AS library_name,
|
||||||
|
COUNT(mi.id) AS item_count
|
||||||
|
FROM hash_conflicts hc
|
||||||
|
JOIN libraries l ON l.id = hc.library_id
|
||||||
|
LEFT JOIN media_items mi ON mi.library_id = hc.library_id AND mi.file_sha256 = hc.file_sha256
|
||||||
|
WHERE hc.status = 'pending'
|
||||||
|
GROUP BY hc.id, hc.library_id, hc.file_sha256, hc.created_at, l.name
|
||||||
|
ORDER BY hc.created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListPendingHashConflictsRow struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||||
|
FileSha256 string `db:"file_sha256" json:"file_sha256"`
|
||||||
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
|
LibraryName string `db:"library_name" json:"library_name"`
|
||||||
|
ItemCount int64 `db:"item_count" json:"item_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListPendingHashConflicts(ctx context.Context) ([]ListPendingHashConflictsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, ListPendingHashConflicts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []ListPendingHashConflictsRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListPendingHashConflictsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.LibraryID,
|
||||||
|
&i.FileSha256,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.LibraryName,
|
||||||
|
&i.ItemCount,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const ListPendingSyncQueueItems = `-- name: ListPendingSyncQueueItems :many
|
const ListPendingSyncQueueItems = `-- name: ListPendingSyncQueueItems :many
|
||||||
SELECT id, device_id, media_item_id, sync_type, sync_data, priority, attempts, max_attempts, status, error_message, created_at, processed_at FROM sync_queue
|
SELECT id, device_id, media_item_id, sync_type, sync_data, priority, attempts, max_attempts, status, error_message, created_at, processed_at FROM sync_queue
|
||||||
WHERE device_id = $1 AND status = 'pending'
|
WHERE device_id = $1 AND status = 'pending'
|
||||||
@@ -9136,6 +9554,21 @@ func (q *Queries) RemoveBookFromKoboShelf(ctx context.Context, arg RemoveBookFro
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ReparentMediaItemChildren = `-- name: ReparentMediaItemChildren :exec
|
||||||
|
SELECT reparent_media_item_children($1::uuid, $2::uuid)
|
||||||
|
`
|
||||||
|
|
||||||
|
type ReparentMediaItemChildrenParams struct {
|
||||||
|
Column1 pgtype.UUID `db:"column_1" json:"column_1"`
|
||||||
|
Column2 pgtype.UUID `db:"column_2" json:"column_2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-parent all child rows of p_source onto p_target (defined in schema.sql)
|
||||||
|
func (q *Queries) ReparentMediaItemChildren(ctx context.Context, arg ReparentMediaItemChildrenParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, ReparentMediaItemChildren, arg.Column1, arg.Column2)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const ResetSystemCollectionMetadata = `-- name: ResetSystemCollectionMetadata :exec
|
const ResetSystemCollectionMetadata = `-- name: ResetSystemCollectionMetadata :exec
|
||||||
UPDATE collections
|
UPDATE collections
|
||||||
SET description = $3,
|
SET description = $3,
|
||||||
@@ -9168,6 +9601,26 @@ func (q *Queries) ResetSystemCollectionMetadata(ctx context.Context, arg ResetSy
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ResolveHashConflict = `-- name: ResolveHashConflict :exec
|
||||||
|
UPDATE hash_conflicts
|
||||||
|
SET status = 'resolved',
|
||||||
|
resolution = $2,
|
||||||
|
resolved_by = $3,
|
||||||
|
resolved_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type ResolveHashConflictParams struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
Resolution pgtype.Text `db:"resolution" json:"resolution"`
|
||||||
|
ResolvedBy pgtype.UUID `db:"resolved_by" json:"resolved_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ResolveHashConflict(ctx context.Context, arg ResolveHashConflictParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, ResolveHashConflict, arg.ID, arg.Resolution, arg.ResolvedBy)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const ResolveProcessingIssue = `-- name: ResolveProcessingIssue :one
|
const ResolveProcessingIssue = `-- name: ResolveProcessingIssue :one
|
||||||
UPDATE processing_issues
|
UPDATE processing_issues
|
||||||
SET resolved = true,
|
SET resolved = true,
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ GROUP BY l.id;
|
|||||||
-- name: CreateMediaItem :one
|
-- name: CreateMediaItem :one
|
||||||
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name)
|
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44)
|
||||||
|
ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW()
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetMediaItem :one
|
-- name: GetMediaItem :one
|
||||||
@@ -1707,6 +1708,70 @@ RETURNING *;
|
|||||||
-- name: GetMediaItemBySHA256 :one
|
-- name: GetMediaItemBySHA256 :one
|
||||||
SELECT * FROM media_items WHERE file_sha256 = $1;
|
SELECT * FROM media_items WHERE file_sha256 = $1;
|
||||||
|
|
||||||
|
-- Get media item by SHA-256 hash within a specific library (content dedup)
|
||||||
|
-- name: GetMediaItemBySHA256AndLibrary :one
|
||||||
|
SELECT * FROM media_items WHERE file_sha256 = $1 AND library_id = $2;
|
||||||
|
|
||||||
|
-- List all media items sharing a SHA-256 hash within a library (hash conflict group)
|
||||||
|
-- name: ListMediaItemsBySHA256AndLibrary :many
|
||||||
|
SELECT * FROM media_items WHERE file_sha256 = $1 AND library_id = $2 ORDER BY file_path;
|
||||||
|
|
||||||
|
-- List media items that have no stored SHA-256 (imported before hashing existed)
|
||||||
|
-- name: ListMediaItemsMissingHash :many
|
||||||
|
SELECT * FROM media_items WHERE file_sha256 IS NULL ORDER BY created_at;
|
||||||
|
|
||||||
|
-- Find content-duplicate groups (same library + SHA-256, more than one row)
|
||||||
|
-- name: FindHashConflictGroups :many
|
||||||
|
SELECT library_id, file_sha256, COUNT(*) AS dup_count
|
||||||
|
FROM media_items
|
||||||
|
WHERE file_sha256 IS NOT NULL
|
||||||
|
GROUP BY library_id, file_sha256
|
||||||
|
HAVING COUNT(*) > 1;
|
||||||
|
|
||||||
|
-- Per-item user-data counts, used when choosing which duplicate copy to keep
|
||||||
|
-- name: GetMediaItemUsageCounts :one
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM reading_progress rp WHERE rp.media_item_id = $1) AS progress_count,
|
||||||
|
(SELECT COUNT(*) FROM media_highlights mh WHERE mh.media_item_id = $1) AS highlights_count,
|
||||||
|
(SELECT COUNT(*) FROM media_bookmarks mb WHERE mb.media_item_id = $1) AS bookmarks_count,
|
||||||
|
(SELECT COUNT(*) FROM media_notes mn WHERE mn.media_item_id = $1) AS notes_count,
|
||||||
|
(SELECT COUNT(*) FROM collection_items ci WHERE ci.media_item_id = $1) AS collections_count;
|
||||||
|
|
||||||
|
-- HASH CONFLICTS QUERIES
|
||||||
|
|
||||||
|
-- Record a pending hash conflict (no-op if the group is already tracked, so
|
||||||
|
-- resolved groups stay resolved and are never re-flagged)
|
||||||
|
-- name: CreateHashConflict :exec
|
||||||
|
INSERT INTO hash_conflicts (library_id, file_sha256)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (library_id, file_sha256) DO NOTHING;
|
||||||
|
|
||||||
|
-- name: ListPendingHashConflicts :many
|
||||||
|
SELECT hc.id, hc.library_id, hc.file_sha256, hc.created_at,
|
||||||
|
l.name AS library_name,
|
||||||
|
COUNT(mi.id) AS item_count
|
||||||
|
FROM hash_conflicts hc
|
||||||
|
JOIN libraries l ON l.id = hc.library_id
|
||||||
|
LEFT JOIN media_items mi ON mi.library_id = hc.library_id AND mi.file_sha256 = hc.file_sha256
|
||||||
|
WHERE hc.status = 'pending'
|
||||||
|
GROUP BY hc.id, hc.library_id, hc.file_sha256, hc.created_at, l.name
|
||||||
|
ORDER BY hc.created_at;
|
||||||
|
|
||||||
|
-- name: GetHashConflict :one
|
||||||
|
SELECT * FROM hash_conflicts WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: ResolveHashConflict :exec
|
||||||
|
UPDATE hash_conflicts
|
||||||
|
SET status = 'resolved',
|
||||||
|
resolution = $2,
|
||||||
|
resolved_by = $3,
|
||||||
|
resolved_at = NOW()
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- Re-parent all child rows of p_source onto p_target (defined in schema.sql)
|
||||||
|
-- name: ReparentMediaItemChildren :exec
|
||||||
|
SELECT reparent_media_item_children($1::uuid, $2::uuid);
|
||||||
|
|
||||||
-- Get media item by OPF identifier
|
-- Get media item by OPF identifier
|
||||||
-- name: GetMediaItemByOPFIdentifier :one
|
-- name: GetMediaItemByOPFIdentifier :one
|
||||||
SELECT * FROM media_items WHERE opf_identifier = $1;
|
SELECT * FROM media_items WHERE opf_identifier = $1;
|
||||||
@@ -1754,6 +1819,11 @@ ORDER BY confidence_score DESC;
|
|||||||
-- name: CreateMediaItemFormat :one
|
-- name: CreateMediaItemFormat :one
|
||||||
INSERT INTO media_item_formats (media_item_id, format_type, file_path, file_sha256, file_size_bytes, mime_type, converted_from_format_id)
|
INSERT INTO media_item_formats (media_item_id, format_type, file_path, file_sha256, file_size_bytes, mime_type, converted_from_format_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
ON CONFLICT (media_item_id, format_type) DO UPDATE SET
|
||||||
|
file_path = EXCLUDED.file_path,
|
||||||
|
file_sha256 = EXCLUDED.file_sha256,
|
||||||
|
file_size_bytes = EXCLUDED.file_size_bytes,
|
||||||
|
mime_type = EXCLUDED.mime_type
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- Get media item formats
|
-- Get media item formats
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HashConflictsHandler serves the admin Hash Conflicts page API: listing
|
||||||
|
// content-duplicate groups (same library + SHA-256 at different paths) and
|
||||||
|
// resolving them by keeping every copy or merging all but one.
|
||||||
|
type HashConflictsHandler struct {
|
||||||
|
db *database.Queries
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHashConflictsHandler(db *database.Queries) *HashConflictsHandler {
|
||||||
|
return &HashConflictsHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashConflictItem is one copy in a conflict group, hydrated with per-item
|
||||||
|
// user-data counts so the admin can make an informed keep/merge choice.
|
||||||
|
type HashConflictItem struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Author string `json:"author,omitempty"`
|
||||||
|
FilePath string `json:"file_path"`
|
||||||
|
FileSize int64 `json:"file_size,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
ProgressCount int64 `json:"progress_count"`
|
||||||
|
HighlightCount int64 `json:"highlight_count"`
|
||||||
|
BookmarkCount int64 `json:"bookmark_count"`
|
||||||
|
NoteCount int64 `json:"note_count"`
|
||||||
|
CollectionCount int64 `json:"collection_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashConflictResponse is one pending conflict group.
|
||||||
|
type HashConflictResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
LibraryID string `json:"library_id"`
|
||||||
|
LibraryName string `json:"library_name"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
Items []HashConflictItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListHashConflicts returns all pending hash conflict groups with their member
|
||||||
|
// items and usage counts.
|
||||||
|
// GET /api/admin/hash-conflicts
|
||||||
|
func (h *HashConflictsHandler) ListHashConflicts(c *echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
|
pending, err := h.db.ListPendingHashConflicts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
|
"error": "failed to list hash conflicts",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
conflicts := make([]HashConflictResponse, 0, len(pending))
|
||||||
|
for _, p := range pending {
|
||||||
|
resp := HashConflictResponse{
|
||||||
|
ID: uuid.UUID(p.ID.Bytes).String(),
|
||||||
|
LibraryID: uuid.UUID(p.LibraryID.Bytes).String(),
|
||||||
|
LibraryName: p.LibraryName,
|
||||||
|
SHA256: p.FileSha256,
|
||||||
|
CreatedAt: p.CreatedAt.Time.Format(time.RFC3339),
|
||||||
|
Items: []HashConflictItem{},
|
||||||
|
}
|
||||||
|
items, err := h.db.ListMediaItemsBySHA256AndLibrary(ctx, database.ListMediaItemsBySHA256AndLibraryParams{
|
||||||
|
FileSha256: pgtype.Text{String: p.FileSha256, Valid: true},
|
||||||
|
LibraryID: p.LibraryID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mi := range items {
|
||||||
|
counts, err := h.db.GetMediaItemUsageCounts(ctx, mi.ID)
|
||||||
|
if err != nil {
|
||||||
|
counts = database.GetMediaItemUsageCountsRow{}
|
||||||
|
}
|
||||||
|
resp.Items = append(resp.Items, HashConflictItem{
|
||||||
|
ID: uuid.UUID(mi.ID.Bytes),
|
||||||
|
Title: mi.Title,
|
||||||
|
Author: mi.Author.String,
|
||||||
|
FilePath: mi.FilePath,
|
||||||
|
FileSize: mi.FileSize.Int64,
|
||||||
|
CreatedAt: mi.CreatedAt.Time.Format(time.RFC3339),
|
||||||
|
ProgressCount: counts.ProgressCount,
|
||||||
|
HighlightCount: counts.HighlightsCount,
|
||||||
|
BookmarkCount: counts.BookmarksCount,
|
||||||
|
NoteCount: counts.NotesCount,
|
||||||
|
CollectionCount: counts.CollectionsCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
conflicts = append(conflicts, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"conflicts": conflicts,
|
||||||
|
"total": len(conflicts),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveHashConflict resolves one conflict group.
|
||||||
|
//
|
||||||
|
// Form/JSON fields:
|
||||||
|
// - action=keep_all both copies are intentional; dismiss
|
||||||
|
// - action=keep&keep_uuid=<uuid> merge every other copy's child rows into the
|
||||||
|
// kept item (progress, highlights, bookmarks,
|
||||||
|
// notes, collections, ...) and delete the losers
|
||||||
|
//
|
||||||
|
// POST /api/admin/hash-conflicts/:id/resolve
|
||||||
|
func (h *HashConflictsHandler) ResolveHashConflict(c *echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
|
conflictID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid conflict ID"})
|
||||||
|
}
|
||||||
|
pgConflictID := pgtype.UUID{Bytes: conflictID, Valid: true}
|
||||||
|
|
||||||
|
conflict, err := h.db.GetHashConflict(ctx, pgConflictID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "conflict not found"})
|
||||||
|
}
|
||||||
|
if conflict.Status != "pending" {
|
||||||
|
return c.JSON(http.StatusConflict, map[string]string{"error": "conflict already resolved"})
|
||||||
|
}
|
||||||
|
|
||||||
|
action := c.FormValue("action")
|
||||||
|
keepUUIDStr := c.FormValue("keep_uuid")
|
||||||
|
if action == "" {
|
||||||
|
// Also accept a JSON body (htmx sends form-encoded, API clients may send JSON)
|
||||||
|
var body struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
KeepUUID string `json:"keep_uuid"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&body); err == nil && body.Action != "" {
|
||||||
|
action = body.Action
|
||||||
|
if keepUUIDStr == "" {
|
||||||
|
keepUUIDStr = body.KeepUUID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var pgUserID pgtype.UUID
|
||||||
|
if userID, ok := c.Get("user_id").(string); ok && userID != "" {
|
||||||
|
if u, err := uuid.Parse(userID); err == nil {
|
||||||
|
pgUserID = pgtype.UUID{Bytes: u, Valid: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "keep_all":
|
||||||
|
if err := h.db.ResolveHashConflict(ctx, database.ResolveHashConflictParams{
|
||||||
|
ID: pgConflictID,
|
||||||
|
Resolution: pgtype.Text{String: "keep_all", Valid: true},
|
||||||
|
ResolvedBy: pgUserID,
|
||||||
|
}); err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to resolve conflict"})
|
||||||
|
}
|
||||||
|
return renderResolved(c, "All copies kept.")
|
||||||
|
|
||||||
|
case "keep":
|
||||||
|
keepUUID, err := uuid.Parse(keepUUIDStr)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "keep_uuid is required for action=keep"})
|
||||||
|
}
|
||||||
|
pgKeepUUID := pgtype.UUID{Bytes: keepUUID, Valid: true}
|
||||||
|
|
||||||
|
// Validate the kept item belongs to this conflict group.
|
||||||
|
items, err := h.db.ListMediaItemsBySHA256AndLibrary(ctx, database.ListMediaItemsBySHA256AndLibraryParams{
|
||||||
|
FileSha256: pgtype.Text{String: conflict.FileSha256, Valid: true},
|
||||||
|
LibraryID: conflict.LibraryID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load conflict group"})
|
||||||
|
}
|
||||||
|
|
||||||
|
keepValid := false
|
||||||
|
for _, mi := range items {
|
||||||
|
if mi.ID.Bytes == pgKeepUUID.Bytes {
|
||||||
|
keepValid = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !keepValid {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "keep_uuid is not part of this conflict"})
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := 0
|
||||||
|
for _, mi := range items {
|
||||||
|
if mi.ID.Bytes == pgKeepUUID.Bytes {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := h.db.ReparentMediaItemChildren(ctx, database.ReparentMediaItemChildrenParams{
|
||||||
|
Column1: pgKeepUUID,
|
||||||
|
Column2: mi.ID,
|
||||||
|
}); err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
|
"error": fmt.Sprintf("failed to merge %q: %v", mi.FilePath, err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := h.db.DeleteMediaItem(ctx, mi.ID); err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
|
"error": fmt.Sprintf("failed to delete %q: %v", mi.FilePath, err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
merged++
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.ResolveHashConflict(ctx, database.ResolveHashConflictParams{
|
||||||
|
ID: pgConflictID,
|
||||||
|
Resolution: pgtype.Text{String: "kept:" + keepUUID.String(), Valid: true},
|
||||||
|
ResolvedBy: pgUserID,
|
||||||
|
}); err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to resolve conflict"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderResolved(c, fmt.Sprintf("Merged %d duplicate cop%s - all reading data preserved.",
|
||||||
|
merged, map[bool]string{true: "y", false: "ies"}[merged == 1]))
|
||||||
|
|
||||||
|
default:
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "action must be 'keep_all' or 'keep'"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderResolved returns the htmx fragment swapped in place of a conflict card.
|
||||||
|
// Built inline (rather than via the templates package) because templates
|
||||||
|
// imports handlers and a back-import would be a cycle.
|
||||||
|
func renderResolved(c *echo.Context, message string) error {
|
||||||
|
html := fmt.Sprintf(`
|
||||||
|
<div class="card p-6 flex items-center gap-3">
|
||||||
|
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0"
|
||||||
|
style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5" aria-hidden="true">
|
||||||
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||||
|
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium" style="color: var(--text-primary);">Conflict resolved</p>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary);">%s</p>
|
||||||
|
</div>
|
||||||
|
</div>`, message)
|
||||||
|
return c.HTML(http.StatusOK, html)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/services"
|
||||||
wsync "bookhoard/internal/sync"
|
wsync "bookhoard/internal/sync"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -22,10 +23,11 @@ type KoboHandler struct {
|
|||||||
progressSvc *wsync.ProgressService
|
progressSvc *wsync.ProgressService
|
||||||
annotationSvc *wsync.AnnotationService
|
annotationSvc *wsync.AnnotationService
|
||||||
libraryService LibraryPathResolver
|
libraryService LibraryPathResolver
|
||||||
|
bookResolver *services.BookResolver
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KoboHandler {
|
func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KoboHandler {
|
||||||
return &KoboHandler{db: db, connManager: connManager}
|
return &KoboHandler{db: db, connManager: connManager, bookResolver: services.NewBookResolver(db)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *KoboHandler) SetProgressService(svc *wsync.ProgressService) {
|
func (h *KoboHandler) SetProgressService(svc *wsync.ProgressService) {
|
||||||
@@ -52,8 +54,9 @@ func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx *echo.Context, contentId s
|
|||||||
|
|
||||||
// Step 2: ContentId not found - check if it looks like a SHA-256 hash
|
// Step 2: ContentId not found - check if it looks like a SHA-256 hash
|
||||||
if len(contentId) == 64 && looksLikeSHA256(contentId) {
|
if len(contentId) == 64 && looksLikeSHA256(contentId) {
|
||||||
// Try to find media item by SHA-256
|
// Try to find media item by SHA-256 (format-aware: also checks
|
||||||
mediaItem, err := h.db.GetMediaItemBySHA256(ctx.Request().Context(), pgtype.Text{String: contentId, Valid: true})
|
// media_item_formats, so a converted/alternate format hash matches).
|
||||||
|
mediaItem, _, err := h.bookResolver.ResolveBySHA256(ctx.Request().Context(), contentId)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Found by SHA-256! Create device catalog entry for future lookups
|
// Found by SHA-256! Create device catalog entry for future lookups
|
||||||
_, _ = h.db.CreateDeviceCatalog(ctx.Request().Context(), database.CreateDeviceCatalogParams{
|
_, _ = h.db.CreateDeviceCatalog(ctx.Request().Context(), database.CreateDeviceCatalogParams{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/services"
|
||||||
wsync "bookhoard/internal/sync"
|
wsync "bookhoard/internal/sync"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -22,6 +23,7 @@ type KOReaderHandler struct {
|
|||||||
progressSvc *wsync.ProgressService
|
progressSvc *wsync.ProgressService
|
||||||
annotationSvc *wsync.AnnotationService
|
annotationSvc *wsync.AnnotationService
|
||||||
libraryService LibraryPathResolver
|
libraryService LibraryPathResolver
|
||||||
|
bookResolver *services.BookResolver
|
||||||
}
|
}
|
||||||
|
|
||||||
type LibraryPathResolver interface {
|
type LibraryPathResolver interface {
|
||||||
@@ -29,7 +31,12 @@ type LibraryPathResolver interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewKOReaderHandler(db *database.Queries, connManager *wsync.ConnectionManager, queue *wsync.SyncQueueProcessor) *KOReaderHandler {
|
func NewKOReaderHandler(db *database.Queries, connManager *wsync.ConnectionManager, queue *wsync.SyncQueueProcessor) *KOReaderHandler {
|
||||||
return &KOReaderHandler{db: db, connManager: connManager, queue: queue}
|
return &KOReaderHandler{
|
||||||
|
db: db,
|
||||||
|
connManager: connManager,
|
||||||
|
queue: queue,
|
||||||
|
bookResolver: services.NewBookResolver(db),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *KOReaderHandler) SetProgressService(svc *wsync.ProgressService) {
|
func (h *KOReaderHandler) SetProgressService(svc *wsync.ProgressService) {
|
||||||
@@ -158,6 +165,7 @@ type KOReaderConflict struct {
|
|||||||
|
|
||||||
type KOReaderMetadata struct {
|
type KOReaderMetadata struct {
|
||||||
UUID string `json:"uuid"`
|
UUID string `json:"uuid"`
|
||||||
|
SHA256 string `json:"sha256,omitempty"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Authors []string `json:"authors"`
|
Authors []string `json:"authors"`
|
||||||
Progress KOReaderProgressData `json:"progress"`
|
Progress KOReaderProgressData `json:"progress"`
|
||||||
@@ -192,6 +200,7 @@ type KOReaderLibraryResponse struct {
|
|||||||
|
|
||||||
type KOReaderLibraryBook struct {
|
type KOReaderLibraryBook struct {
|
||||||
UUID string `json:"uuid"`
|
UUID string `json:"uuid"`
|
||||||
|
SHA256 string `json:"sha256,omitempty"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Author string `json:"author"`
|
Author string `json:"author"`
|
||||||
ContentType string `json:"content_type"`
|
ContentType string `json:"content_type"`
|
||||||
@@ -303,8 +312,10 @@ func (h *KOReaderHandler) resolveBookToMediaItem(c *echo.Context, deviceID pgtyp
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: SHA-256 provided (medium confidence - 0.9)
|
// Priority 2: SHA-256 provided (medium confidence - 0.9)
|
||||||
|
// Uses the shared BookResolver, which also checks per-format hashes
|
||||||
|
// (media_item_formats) so a converted file (KEPUB/PDF) matches too.
|
||||||
if book.SHA256 != "" && len(book.SHA256) == 64 {
|
if book.SHA256 != "" && len(book.SHA256) == 64 {
|
||||||
mediaItem, err := h.db.GetMediaItemBySHA256(ctx, pgtype.Text{String: book.SHA256, Valid: true})
|
mediaItem, _, err := h.bookResolver.ResolveBySHA256(ctx, book.SHA256)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Create device file alias if FilePath is provided
|
// Create device file alias if FilePath is provided
|
||||||
if book.FilePath != "" {
|
if book.FilePath != "" {
|
||||||
@@ -883,6 +894,7 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
|||||||
|
|
||||||
metadata := KOReaderMetadata{
|
metadata := KOReaderMetadata{
|
||||||
UUID: bookUUID.String(),
|
UUID: bookUUID.String(),
|
||||||
|
SHA256: mediaItem.FileSha256.String,
|
||||||
Title: mediaItem.Title,
|
Title: mediaItem.Title,
|
||||||
Authors: []string{mediaItem.Author.String},
|
Authors: []string{mediaItem.Author.String},
|
||||||
Progress: progressData,
|
Progress: progressData,
|
||||||
@@ -989,6 +1001,7 @@ func (h *KOReaderHandler) GetLibrary(c *echo.Context) error {
|
|||||||
|
|
||||||
libraryBooks = append(libraryBooks, KOReaderLibraryBook{
|
libraryBooks = append(libraryBooks, KOReaderLibraryBook{
|
||||||
UUID: uuid.UUID(item.ID.Bytes).String(),
|
UUID: uuid.UUID(item.ID.Bytes).String(),
|
||||||
|
SHA256: item.FileSha256.String,
|
||||||
Title: item.Title,
|
Title: item.Title,
|
||||||
Author: item.Author.String,
|
Author: item.Author.String,
|
||||||
ContentType: "6",
|
ContentType: "6",
|
||||||
@@ -1045,8 +1058,8 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
pgBookUUID = pgtype.UUID{Bytes: bookUUID, Valid: true}
|
pgBookUUID = pgtype.UUID{Bytes: bookUUID, Valid: true}
|
||||||
} else if req.BookSHA256 != "" && len(req.BookSHA256) == 64 {
|
} else if req.BookSHA256 != "" && len(req.BookSHA256) == 64 {
|
||||||
// Use SHA-256 to find book
|
// Use SHA-256 to find book (format-aware: also checks media_item_formats)
|
||||||
mediaItem, err := h.db.GetMediaItemBySHA256(ctx, pgtype.Text{String: req.BookSHA256, Valid: true})
|
mediaItem, _, err := h.bookResolver.ResolveBySHA256(ctx, req.BookSHA256)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{
|
return c.JSON(http.StatusNotFound, map[string]string{
|
||||||
"error": "book not found by SHA-256",
|
"error": "book not found by SHA-256",
|
||||||
@@ -1068,7 +1081,7 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
|||||||
|
|
||||||
// If bookmark has its own SHA-256, use it for matching
|
// If bookmark has its own SHA-256, use it for matching
|
||||||
if bookmark.BookSHA256 != "" && len(bookmark.BookSHA256) == 64 {
|
if bookmark.BookSHA256 != "" && len(bookmark.BookSHA256) == 64 {
|
||||||
mediaItem, err := h.db.GetMediaItemBySHA256(ctx, pgtype.Text{String: bookmark.BookSHA256, Valid: true})
|
mediaItem, _, err := h.bookResolver.ResolveBySHA256(ctx, bookmark.BookSHA256)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
mediaItemID = mediaItem.ID
|
mediaItemID = mediaItem.ID
|
||||||
}
|
}
|
||||||
@@ -1119,7 +1132,7 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
|||||||
|
|
||||||
// If note has its own SHA-256, use it for matching
|
// If note has its own SHA-256, use it for matching
|
||||||
if note.BookSHA256 != "" && len(note.BookSHA256) == 64 {
|
if note.BookSHA256 != "" && len(note.BookSHA256) == 64 {
|
||||||
mediaItem, err := h.db.GetMediaItemBySHA256(ctx, pgtype.Text{String: note.BookSHA256, Valid: true})
|
mediaItem, _, err := h.bookResolver.ResolveBySHA256(ctx, note.BookSHA256)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
mediaItemID = mediaItem.ID
|
mediaItemID = mediaItem.ID
|
||||||
}
|
}
|
||||||
@@ -1169,7 +1182,7 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
|||||||
|
|
||||||
// If highlight has its own SHA-256, use it for matching
|
// If highlight has its own SHA-256, use it for matching
|
||||||
if highlight.BookSHA256 != "" && len(highlight.BookSHA256) == 64 {
|
if highlight.BookSHA256 != "" && len(highlight.BookSHA256) == 64 {
|
||||||
mediaItem, err := h.db.GetMediaItemBySHA256(ctx, pgtype.Text{String: highlight.BookSHA256, Valid: true})
|
mediaItem, _, err := h.bookResolver.ResolveBySHA256(ctx, highlight.BookSHA256)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
mediaItemID = mediaItem.ID
|
mediaItemID = mediaItem.ID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -605,6 +605,12 @@ func (h *OPDSHandler) DownloadBook(c *echo.Context) error {
|
|||||||
if mediaItem.MimeType.Valid {
|
if mediaItem.MimeType.Valid {
|
||||||
mimeType = mediaItem.MimeType.String
|
mimeType = mediaItem.MimeType.String
|
||||||
}
|
}
|
||||||
|
// Always expose the primary content hash so clients (e.g. the koreader
|
||||||
|
// plugin) learn the canonical SHA-256 from the download response itself,
|
||||||
|
// not just from the feed metadata.
|
||||||
|
if mediaItem.FileSha256.Valid {
|
||||||
|
fileSha256 = mediaItem.FileSha256.String
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file exists
|
// Check if file exists
|
||||||
|
|||||||
@@ -134,6 +134,21 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
|
|||||||
SHA256: item.FileSha256.String,
|
SHA256: item.FileSha256.String,
|
||||||
FilePath: item.FilePath,
|
FilePath: item.FilePath,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also key the book by each per-format hash (KEPUB/PDF/...), so a device
|
||||||
|
// holding a converted format resolves via the sidecar the same way it
|
||||||
|
// would via BookResolver on the server.
|
||||||
|
formats, ferr := h.db.GetMediaItemFormats(ctx, item.ID)
|
||||||
|
if ferr == nil {
|
||||||
|
entry := books[key]
|
||||||
|
for _, f := range formats {
|
||||||
|
if f.FileSha256.Valid && f.FileSha256.String != "" {
|
||||||
|
if _, exists := books[f.FileSha256.String]; !exists {
|
||||||
|
books[f.FileSha256.String] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get collections
|
// Get collections
|
||||||
@@ -269,6 +284,21 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
|
|||||||
SHA256: item.FileSha256.String,
|
SHA256: item.FileSha256.String,
|
||||||
FilePath: item.FilePath,
|
FilePath: item.FilePath,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also key the book by each per-format hash (KEPUB/PDF/...), so a device
|
||||||
|
// holding a converted format resolves via the sidecar the same way it
|
||||||
|
// would via BookResolver on the server.
|
||||||
|
formats, ferr := h.db.GetMediaItemFormats(ctx, item.ID)
|
||||||
|
if ferr == nil {
|
||||||
|
entry := books[key]
|
||||||
|
for _, f := range formats {
|
||||||
|
if f.FileSha256.Valid && f.FileSha256.String != "" {
|
||||||
|
if _, exists := books[f.FileSha256.String]; !exists {
|
||||||
|
books[f.FileSha256.String] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get collections
|
// Get collections
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -944,6 +945,67 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
return c.HTML(http.StatusOK, buf.String())
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Admin hash conflicts page: content-duplicate groups flagged during hash
|
||||||
|
// backfill or rescan, resolved by keeping all copies or merging into one.
|
||||||
|
frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||||
|
user, err := getTemplateUserWithTheme(c, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||||
|
}
|
||||||
|
|
||||||
|
pending, err := cfg.Queries.ListPendingHashConflicts(c.Request().Context())
|
||||||
|
if err != nil {
|
||||||
|
return renderErrorPage(c, "Error loading hash conflicts", "conflicts_load_error")
|
||||||
|
}
|
||||||
|
|
||||||
|
conflicts := make([]templates.HashConflictData, 0, len(pending))
|
||||||
|
for _, p := range pending {
|
||||||
|
conflict := templates.HashConflictData{
|
||||||
|
ID: uuid.UUID(p.ID.Bytes).String(),
|
||||||
|
LibraryName: p.LibraryName,
|
||||||
|
SHA256: p.FileSha256,
|
||||||
|
SHAShort: p.FileSha256[:16] + "…",
|
||||||
|
CreatedAt: p.CreatedAt.Time.Format("Jan 2, 2006"),
|
||||||
|
Items: []templates.HashConflictItemData{},
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := cfg.Queries.ListMediaItemsBySHA256AndLibrary(c.Request().Context(), database.ListMediaItemsBySHA256AndLibraryParams{
|
||||||
|
FileSha256: pgtype.Text{String: p.FileSha256, Valid: true},
|
||||||
|
LibraryID: p.LibraryID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mi := range items {
|
||||||
|
counts, err := cfg.Queries.GetMediaItemUsageCounts(c.Request().Context(), mi.ID)
|
||||||
|
if err != nil {
|
||||||
|
counts = database.GetMediaItemUsageCountsRow{}
|
||||||
|
}
|
||||||
|
totalData := counts.ProgressCount + counts.HighlightsCount + counts.BookmarksCount + counts.NotesCount + counts.CollectionsCount
|
||||||
|
conflict.Items = append(conflict.Items, templates.HashConflictItemData{
|
||||||
|
ID: uuid.UUID(mi.ID.Bytes).String(),
|
||||||
|
Title: mi.Title,
|
||||||
|
Author: mi.Author.String,
|
||||||
|
FilePath: mi.FilePath,
|
||||||
|
FileSize: mi.FileSize.Int64,
|
||||||
|
UsageSummary: fmt.Sprintf("%d progress, %d highlights, %d bookmarks, %d notes, %d collections",
|
||||||
|
counts.ProgressCount, counts.HighlightsCount, counts.BookmarksCount, counts.NotesCount, counts.CollectionsCount),
|
||||||
|
HasReadingData: totalData > 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
conflicts = append(conflicts, conflict)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = templates.AdminHashConflicts(user, conflicts).Render(c.Request().Context(), &buf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
|
}))
|
||||||
|
|
||||||
// Admin users page
|
// Admin users page
|
||||||
frontendProtected.GET("/admin/users", handlers.AdminMiddleware(func(c *echo.Context) error {
|
frontendProtected.GET("/admin/users", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||||
user, err := getTemplateUserWithTheme(c, cfg)
|
user, err := getTemplateUserWithTheme(c, cfg)
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ type Config struct {
|
|||||||
MediaHandler *handlers.MediaHandler
|
MediaHandler *handlers.MediaHandler
|
||||||
MatchingHandler *handlers.MatchingHandler
|
MatchingHandler *handlers.MatchingHandler
|
||||||
ProcessingIssuesHandler *handlers.ProcessingIssuesHandler
|
ProcessingIssuesHandler *handlers.ProcessingIssuesHandler
|
||||||
|
HashConflictsHandler *handlers.HashConflictsHandler
|
||||||
KOReaderHandler *handlers.KOReaderHandler
|
KOReaderHandler *handlers.KOReaderHandler
|
||||||
WSHandler *handlers.WSHandler
|
WSHandler *handlers.WSHandler
|
||||||
ConflictHandler *handlers.ConflictHandler
|
ConflictHandler *handlers.ConflictHandler
|
||||||
@@ -294,6 +295,17 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// One-time hash backfill: compute and store SHA-256 for media items
|
||||||
|
// imported before hashing existed, then flag any content-duplicate groups
|
||||||
|
// for admin review on the Hash Conflicts page. Runs independently of
|
||||||
|
// auto-scan (it is a one-shot self-heal, not a recurring scan) and is a
|
||||||
|
// no-op once every item is hashed. Delayed so it does not compete with
|
||||||
|
// startup scans for disk I/O.
|
||||||
|
go func() {
|
||||||
|
time.Sleep(30 * time.Second)
|
||||||
|
services.NewHashBackfillService(cfg.Queries).Run(context.Background())
|
||||||
|
}()
|
||||||
|
|
||||||
// Register progress routes with actual handler
|
// Register progress routes with actual handler
|
||||||
registerProgressRoutes(cfg, scannerHandler)
|
registerProgressRoutes(cfg, scannerHandler)
|
||||||
|
|
||||||
@@ -301,5 +313,9 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
|||||||
admin := protected.Group("", handlers.AdminMiddleware)
|
admin := protected.Group("", handlers.AdminMiddleware)
|
||||||
registerScannerRoutes(admin, scannerHandler)
|
registerScannerRoutes(admin, scannerHandler)
|
||||||
|
|
||||||
|
// Hash conflict routes (admin only)
|
||||||
|
admin.GET("/api/admin/hash-conflicts", cfg.HashConflictsHandler.ListHashConflicts)
|
||||||
|
admin.POST("/api/admin/hash-conflicts/:id/resolve", cfg.HashConflictsHandler.ResolveHashConflict)
|
||||||
|
|
||||||
return scannerHandler
|
return scannerHandler
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,13 +46,15 @@ type LinkBookRequest struct {
|
|||||||
|
|
||||||
// BookMatchingService handles universal book matching
|
// BookMatchingService handles universal book matching
|
||||||
type BookMatchingService struct {
|
type BookMatchingService struct {
|
||||||
db *database.Queries
|
db *database.Queries
|
||||||
|
resolver *BookResolver
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBookMatchingService creates a new book matching service
|
// NewBookMatchingService creates a new book matching service
|
||||||
func NewBookMatchingService(db *database.Queries) *BookMatchingService {
|
func NewBookMatchingService(db *database.Queries) *BookMatchingService {
|
||||||
return &BookMatchingService{
|
return &BookMatchingService{
|
||||||
db: db,
|
db: db,
|
||||||
|
resolver: NewBookResolver(db),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,27 +169,21 @@ func (s *BookMatchingService) matchByOPFUUID(ctx context.Context, identifiers []
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// matchBySHA256 attempts to match by file SHA-256 hash
|
// matchBySHA256 attempts to match by file SHA-256 hash.
|
||||||
|
// Uses the shared BookResolver so it is both indexed (no full-table scan) and
|
||||||
|
// format-aware: a converted/alternate format hash (media_item_formats) matches
|
||||||
|
// in addition to the primary media_items.file_sha256.
|
||||||
func (s *BookMatchingService) matchBySHA256(ctx context.Context, sha256 string) *BookMatch {
|
func (s *BookMatchingService) matchBySHA256(ctx context.Context, sha256 string) *BookMatch {
|
||||||
items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{
|
item, method, err := s.resolver.ResolveBySHA256(ctx, sha256)
|
||||||
Limit: 1000,
|
if err != nil || !item.ID.Valid {
|
||||||
Offset: 0,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
return &BookMatch{
|
||||||
for _, item := range items {
|
MediaItemID: item.ID.Bytes,
|
||||||
if item.FileSha256.Valid && item.FileSha256.String == sha256 {
|
BookhoardUUID: item.ID.Bytes,
|
||||||
return &BookMatch{
|
Confidence: 0.9,
|
||||||
MediaItemID: item.ID.Bytes,
|
MatchMethod: "sha256_" + string(method),
|
||||||
BookhoardUUID: item.ID.Bytes,
|
|
||||||
Confidence: 0.9,
|
|
||||||
MatchMethod: "sha256_match",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// matchByOPFIdentifier attempts to match by OPF identifier
|
// matchByOPFIdentifier attempts to match by OPF identifier
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResolveMethod describes how a media item was resolved from a client-supplied identifier.
|
||||||
|
type ResolveMethod string
|
||||||
|
|
||||||
|
const (
|
||||||
|
MethodNone ResolveMethod = ""
|
||||||
|
MethodSHA256 ResolveMethod = "sha256" // matched on media_items.file_sha256
|
||||||
|
MethodSHA256Format ResolveMethod = "sha256_format" // matched on media_item_formats.file_sha256 (converted/alternate format)
|
||||||
|
)
|
||||||
|
|
||||||
|
// BookResolver is the single shared path from a client-supplied identifier to a
|
||||||
|
// media_item.
|
||||||
|
//
|
||||||
|
// All client/sync interfaces (koreader, kobo, OPDS, the device-link UI, and any
|
||||||
|
// future mobile app) should resolve books through BookResolver so they share
|
||||||
|
// identical matching semantics. In particular it provides format-aware SHA-256
|
||||||
|
// matching: a converted file (KEPUB/PDF) whose hash lives in media_item_formats
|
||||||
|
// resolves just as well as the primary format. The import-time SHA-256 is the
|
||||||
|
// canonical shared identifier across every client.
|
||||||
|
type BookResolver struct {
|
||||||
|
db *database.Queries
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBookResolver constructs a resolver backed by the given queries.
|
||||||
|
func NewBookResolver(db *database.Queries) *BookResolver {
|
||||||
|
return &BookResolver{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveBySHA256 resolves a media item by its content hash. It checks the
|
||||||
|
// primary media_items.file_sha256 first, then media_item_formats.file_sha256 so
|
||||||
|
// that a converted/alternate format (KEPUB, PDF, ...) also matches. Returns the
|
||||||
|
// matched item and how it matched, or pgx.ErrNoRows when no item has this hash.
|
||||||
|
func (r *BookResolver) ResolveBySHA256(ctx context.Context, sha256 string) (database.MediaItems, ResolveMethod, error) {
|
||||||
|
if sha256 == "" {
|
||||||
|
return database.MediaItems{}, MethodNone, pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
sha := pgtype.Text{String: sha256, Valid: true}
|
||||||
|
|
||||||
|
// 1. Primary content hash (the file the media item was imported from).
|
||||||
|
if mi, err := r.db.GetMediaItemBySHA256(ctx, sha); err == nil {
|
||||||
|
return mi, MethodSHA256, nil
|
||||||
|
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return database.MediaItems{}, MethodNone, fmt.Errorf("resolve by sha256 (primary): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Per-format hash (a converted/alternate format: KEPUB, PDF, ...).
|
||||||
|
formatRow, err := r.db.GetMediaItemFormatBySHA256(ctx, sha)
|
||||||
|
if err == nil {
|
||||||
|
if mi, err := r.db.GetMediaItem(ctx, formatRow.MediaItemID); err == nil {
|
||||||
|
return mi, MethodSHA256Format, nil
|
||||||
|
}
|
||||||
|
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return database.MediaItems{}, MethodNone, fmt.Errorf("resolve by sha256 (format): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return database.MediaItems{}, MethodNone, pgx.ErrNoRows
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HashBackfillService is a one-time self-heal pass that computes and stores the
|
||||||
|
// SHA-256 for media items imported before hashing existed (file_sha256 IS
|
||||||
|
// NULL). It runs once shortly after startup, independently of auto-scan, and
|
||||||
|
// also performs a final conflict sweep that flags any content-duplicate groups
|
||||||
|
// (same library + SHA-256 at different paths) on the admin Hash Conflicts page.
|
||||||
|
//
|
||||||
|
// The sweep runs after the per-item pass because during the pass only one side
|
||||||
|
// of a preexisting duplicate pair may be hashed at a time - the group only
|
||||||
|
// becomes visible once every item has its hash.
|
||||||
|
type HashBackfillService struct {
|
||||||
|
db *database.Queries
|
||||||
|
libSvc *LibraryService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHashBackfillService creates a backfill service.
|
||||||
|
func NewHashBackfillService(db *database.Queries) *HashBackfillService {
|
||||||
|
return &HashBackfillService{db: db, libSvc: NewLibraryService(db)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run performs the backfill pass followed by the conflict sweep. It logs
|
||||||
|
// progress and never returns an error - failures on individual items are
|
||||||
|
// skipped so one unreadable file cannot block the rest.
|
||||||
|
func (s *HashBackfillService) Run(ctx context.Context) {
|
||||||
|
items, err := s.db.ListMediaItemsMissingHash(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[HASH-BACKFILL] failed to list items missing hash: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
log.Printf("[HASH-BACKFILL] all media items already hashed, nothing to do")
|
||||||
|
s.sweepConflicts(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[HASH-BACKFILL] computing SHA-256 for %d unhashed media items", len(items))
|
||||||
|
started := time.Now()
|
||||||
|
hashed, failed := 0, 0
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
log.Printf("[HASH-BACKFILL] cancelled after %d items", hashed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path, err := s.libSvc.ResolveMediaPath(ctx, item.LibraryID, item.FilePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[HASH-BACKFILL] could not resolve path for %q: %v", item.FilePath, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sha, err := computeFileSHA256(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[HASH-BACKFILL] could not hash %q: %v", path, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.db.UpdateMediaItemIdentifiers(ctx, database.UpdateMediaItemIdentifiersParams{
|
||||||
|
ID: item.ID,
|
||||||
|
FileSha256: pgtype.Text{String: sha, Valid: true},
|
||||||
|
HashConfidence: pgtype.Text{String: "sha256_full", Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[HASH-BACKFILL] could not store hash for %q: %v", item.FilePath, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hashed++
|
||||||
|
|
||||||
|
if hashed%25 == 0 {
|
||||||
|
log.Printf("[HASH-BACKFILL] progress: %d/%d hashed", hashed, len(items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[HASH-BACKFILL] done in %s: %d hashed, %d failed (of %d)",
|
||||||
|
time.Since(started).Round(time.Second), hashed, failed, len(items))
|
||||||
|
|
||||||
|
s.sweepConflicts(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweepConflicts flags every content-duplicate group (same library + SHA-256,
|
||||||
|
// more than one item) as a pending hash conflict. The upsert is a no-op for
|
||||||
|
// groups that are already tracked or resolved, so admins who chose "keep both"
|
||||||
|
// are never re-prompted.
|
||||||
|
func (s *HashBackfillService) sweepConflicts(ctx context.Context) {
|
||||||
|
groups, err := s.db.FindHashConflictGroups(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[HASH-BACKFILL] conflict sweep failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(groups) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
flagged := 0
|
||||||
|
for _, g := range groups {
|
||||||
|
if err := s.db.CreateHashConflict(ctx, database.CreateHashConflictParams{
|
||||||
|
LibraryID: g.LibraryID,
|
||||||
|
FileSha256: g.FileSha256.String,
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("[HASH-BACKFILL] could not record conflict group: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
flagged++
|
||||||
|
}
|
||||||
|
log.Printf("[HASH-BACKFILL] flagged %d content-duplicate group(s) for admin review", flagged)
|
||||||
|
}
|
||||||
@@ -699,14 +699,24 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
|||||||
if err := s.updateMediaItem(ctx, existingItem.ID, path, info); err != nil {
|
if err := s.updateMediaItem(ctx, existingItem.ID, path, info); err != nil {
|
||||||
fmt.Printf("Warning: failed to update existing media item: %v\n", err)
|
fmt.Printf("Warning: failed to update existing media item: %v\n", err)
|
||||||
}
|
}
|
||||||
|
// Recompute hash identifiers too - a force rescan is the admin's
|
||||||
|
// backfill tool and must refresh stale or missing hashes.
|
||||||
|
s.recomputeHashInfo(ctx, existingItem.ID, libraryID, path)
|
||||||
return false, nil
|
return false, nil
|
||||||
} else {
|
} else {
|
||||||
// Normal behavior: check if file has changed (by size)
|
// Normal behavior: check if file has changed (by size)
|
||||||
if existingItem.FileSize.Int64 != info.Size() {
|
if existingItem.FileSize.Int64 != info.Size() {
|
||||||
fmt.Printf("File size changed, updating media item: %s\n", path)
|
fmt.Printf("File size changed, updating media item: %s\n", path)
|
||||||
_ = s.updateMediaItem(ctx, existingItem.ID, path, info)
|
_ = s.updateMediaItem(ctx, existingItem.ID, path, info)
|
||||||
|
// The bytes changed, so any stored hash is stale.
|
||||||
|
s.recomputeHashInfo(ctx, existingItem.ID, libraryID, path)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
// Self-heal items imported before hashing existed: even an unchanged
|
||||||
|
// file gets its hash computed if missing.
|
||||||
|
if !existingItem.FileSha256.Valid || existingItem.FileSha256.String == "" {
|
||||||
|
s.recomputeHashInfo(ctx, existingItem.ID, libraryID, path)
|
||||||
|
}
|
||||||
fmt.Printf("Media item already exists with same size, skipping: %s\n", path)
|
fmt.Printf("Media item already exists with same size, skipping: %s\n", path)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -735,6 +745,26 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
|||||||
path, hashInfo.FileSHA256, hashInfo.OPFIdentifier, hashInfo.OPFUUID, hashInfo.HashConfidence)
|
path, hashInfo.FileSHA256, hashInfo.OPFIdentifier, hashInfo.OPFUUID, hashInfo.HashConfidence)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Content dedup: if an item with the same SHA-256 already exists in this
|
||||||
|
// library (same file at a different path), treat it as existing rather than
|
||||||
|
// creating a duplicate. The file bytes are identical, so metadata matches.
|
||||||
|
if hashInfo.FileSHA256 != "" {
|
||||||
|
existingByHash, err := s.db.GetMediaItemBySHA256AndLibrary(ctx, database.GetMediaItemBySHA256AndLibraryParams{
|
||||||
|
FileSha256: pgtype.Text{String: hashInfo.FileSHA256, Valid: true},
|
||||||
|
LibraryID: libraryID,
|
||||||
|
})
|
||||||
|
if err == nil && existingByHash.ID.Valid {
|
||||||
|
fmt.Printf("Media item with same SHA-256 already exists in library (path %q), skipping duplicate: %s\n",
|
||||||
|
existingByHash.FilePath, path)
|
||||||
|
if s.forceRescan {
|
||||||
|
_ = s.updateMediaItem(ctx, existingByHash.ID, path, info)
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
} else if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
fmt.Printf("Warning: failed to check media item by SHA-256 for %s: %v\n", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// REMOVED: Comic metadata extraction now handled by mergeMetadata()
|
// REMOVED: Comic metadata extraction now handled by mergeMetadata()
|
||||||
// This avoids duplicate extraction and ensures smart merging happens
|
// This avoids duplicate extraction and ensures smart merging happens
|
||||||
|
|
||||||
@@ -2595,6 +2625,68 @@ func (s *MediaScanner) getMediaItemByFilePath(ctx context.Context, filePath stri
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recomputeHashInfo recomputes the file's hash identifiers and stores them on
|
||||||
|
// the media item (plus its per-format row). Called on force rescan, on file
|
||||||
|
// size change, and when an unchanged item is found with no stored hash, so
|
||||||
|
// items imported before hashing existed are backfilled by ordinary scans.
|
||||||
|
// After storing, it records a hash conflict if the same content now exists at
|
||||||
|
// more than one path in the library.
|
||||||
|
func (s *MediaScanner) recomputeHashInfo(ctx context.Context, mediaItemID pgtype.UUID, libraryID pgtype.UUID, path string) {
|
||||||
|
hashInfo, formatInfo, err := s.extractHashInfo(path)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: failed to extract hash info from %s: %v\n", path, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hashInfo == nil || hashInfo.FileSHA256 == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.db.UpdateMediaItemIdentifiers(ctx, database.UpdateMediaItemIdentifiersParams{
|
||||||
|
ID: mediaItemID,
|
||||||
|
FileSha256: pgtype.Text{String: hashInfo.FileSHA256, Valid: true},
|
||||||
|
OpfIdentifier: pgtype.Text{String: hashInfo.OPFIdentifier, Valid: hashInfo.OPFIdentifier != ""},
|
||||||
|
OpfUuid: pgtype.Text{String: hashInfo.OPFUUID, Valid: hashInfo.OPFUUID != ""},
|
||||||
|
HashConfidence: pgtype.Text{String: hashInfo.HashConfidence, Valid: hashInfo.HashConfidence != ""},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: failed to update hash identifiers for %s: %v\n", path, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if formatInfo != nil {
|
||||||
|
_, _ = s.db.CreateMediaItemFormat(ctx, database.CreateMediaItemFormatParams{
|
||||||
|
MediaItemID: mediaItemID,
|
||||||
|
FormatType: formatInfo.FormatType,
|
||||||
|
FilePath: pgtype.Text{String: s.getRelativePath(formatInfo.FilePath), Valid: true},
|
||||||
|
FileSha256: pgtype.Text{String: formatInfo.FileSHA256, Valid: true},
|
||||||
|
FileSizeBytes: pgtype.Int8{Int64: formatInfo.FileSizeBytes, Valid: true},
|
||||||
|
MimeType: pgtype.Text{String: formatInfo.MimeType, Valid: true},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
s.recordHashConflictIfAny(ctx, libraryID, hashInfo.FileSHA256)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordHashConflictIfAny flags a pending hash conflict when the given content
|
||||||
|
// hash is now shared by more than one media item in the same library. The
|
||||||
|
// upsert is a no-op for already-tracked (including resolved) groups.
|
||||||
|
func (s *MediaScanner) recordHashConflictIfAny(ctx context.Context, libraryID pgtype.UUID, fileSHA256 string) {
|
||||||
|
items, err := s.db.ListMediaItemsBySHA256AndLibrary(ctx, database.ListMediaItemsBySHA256AndLibraryParams{
|
||||||
|
FileSha256: pgtype.Text{String: fileSHA256, Valid: true},
|
||||||
|
LibraryID: libraryID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(items) > 1 {
|
||||||
|
fmt.Printf("Hash conflict: %d media items share SHA-256 %s in one library\n", len(items), fileSHA256)
|
||||||
|
_ = s.db.CreateHashConflict(ctx, database.CreateHashConflictParams{
|
||||||
|
LibraryID: libraryID,
|
||||||
|
FileSha256: fileSHA256,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MediaScanner) getMimeType(path string) string {
|
func (s *MediaScanner) getMimeType(path string) string {
|
||||||
ext := strings.ToLower(filepath.Ext(path))
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
if mime, ok := MimeTypes[ext]; ok {
|
if mime, ok := MimeTypes[ext]; ok {
|
||||||
@@ -3018,6 +3110,12 @@ func (s *MediaScanner) startBackupScan(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *MediaScanner) calculateFileSHA256(filePath string) (string, error) {
|
func (s *MediaScanner) calculateFileSHA256(filePath string) (string, error) {
|
||||||
|
return computeFileSHA256(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeFileSHA256 is the package-level full-file SHA-256 used by the hash
|
||||||
|
// backfill service; the MediaScanner method delegates to it.
|
||||||
|
func computeFileSHA256(filePath string) (string, error) {
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to open file: %v", err)
|
return "", fmt.Errorf("failed to open file: %v", err)
|
||||||
|
|||||||
@@ -403,6 +403,7 @@ func (s *ReaderService) getDefaultSettings() map[string]interface{} {
|
|||||||
"tap_zone_size": 30,
|
"tap_zone_size": 30,
|
||||||
"auto_scroll": false,
|
"auto_scroll": false,
|
||||||
"panel_zoom_enabled": true,
|
"panel_zoom_enabled": true,
|
||||||
|
"double_page_spread": true,
|
||||||
|
|
||||||
// Dockable panel defaults
|
// Dockable panel defaults
|
||||||
"panel_layout": map[string]interface{}{
|
"panel_layout": map[string]interface{}{
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
"dev": "npm run build:ts:dev && npm run build:css"
|
"dev": "npm run build:ts:dev && npm run build:css"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bookhoard/foliate-js": "github:john-okeefe/foliate-js#d164d6f",
|
"@bookhoard/foliate-js": "git+https://github.com/john-okeefe/foliate-js.git#d4d87a9",
|
||||||
"alpinejs": "^3.15.8",
|
"alpinejs": "^3.15.8",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
-- scripts/dedup_media_items.sql
|
||||||
|
--
|
||||||
|
-- Detects and removes duplicate media_items, re-parenting all child rows
|
||||||
|
-- (reading progress, highlights, collections, etc.) onto a single survivor
|
||||||
|
-- before deleting the losers.
|
||||||
|
--
|
||||||
|
-- Two kinds of duplicates are handled:
|
||||||
|
-- 1. PATH duplicates — same (library_id, file_path), multiple rows.
|
||||||
|
-- These block the UNIQUE(library_id, file_path)
|
||||||
|
-- constraint added by the schema migration.
|
||||||
|
-- 2. CONTENT duplicates — same file_sha256 within a library, different paths.
|
||||||
|
-- Same file imported twice under two names.
|
||||||
|
--
|
||||||
|
-- This script is IDEMPOTENT: re-running it is a no-op once the data is clean.
|
||||||
|
-- It is safe to run against any Bookhoard database, before or after upgrading.
|
||||||
|
--
|
||||||
|
-- Usage:
|
||||||
|
-- psql -h <host> -U postgres -d bookhoard -f scripts/dedup_media_items.sql
|
||||||
|
--
|
||||||
|
-- The first section is a DRY-RUN report (SELECTs only, no writes). The cleanup
|
||||||
|
-- runs inside an explicit transaction. Comment out the cleanup block to inspect
|
||||||
|
-- first.
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- DRY RUN: report duplicates (no writes)
|
||||||
|
-- ======================================================================
|
||||||
|
|
||||||
|
\echo '=== PATH duplicates (library_id + file_path) ==='
|
||||||
|
SELECT library_id,
|
||||||
|
file_path,
|
||||||
|
COUNT(*) AS dupes,
|
||||||
|
array_agg(id::text) AS media_item_ids,
|
||||||
|
array_agg(COALESCE(file_sha256::text, 'NULL')) AS hashes
|
||||||
|
FROM media_items
|
||||||
|
GROUP BY library_id, file_path
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
ORDER BY COUNT(*) DESC;
|
||||||
|
|
||||||
|
\echo '=== CONTENT duplicates (same file_sha256 within a library, different paths) ==='
|
||||||
|
SELECT library_id,
|
||||||
|
file_sha256::text AS hash,
|
||||||
|
COUNT(*) AS dupes,
|
||||||
|
array_agg(file_path) AS paths,
|
||||||
|
array_agg(id::text) AS media_item_ids
|
||||||
|
FROM media_items
|
||||||
|
WHERE file_sha256 IS NOT NULL
|
||||||
|
GROUP BY library_id, file_sha256
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
ORDER BY COUNT(*) DESC;
|
||||||
|
|
||||||
|
\echo '=== Child-row counts per duplicate candidate (helps confirm survivor choice) ==='
|
||||||
|
SELECT mi.id,
|
||||||
|
mi.library_id,
|
||||||
|
mi.file_path,
|
||||||
|
(SELECT COUNT(*) FROM reading_progress rp WHERE rp.media_item_id = mi.id) AS progress,
|
||||||
|
(SELECT COUNT(*) FROM media_highlights mh WHERE mh.media_item_id = mi.id) AS highlights,
|
||||||
|
(SELECT COUNT(*) FROM media_bookmarks mb WHERE mb.media_item_id = mi.id) AS bookmarks,
|
||||||
|
(SELECT COUNT(*) FROM media_notes mn WHERE mn.media_item_id = mi.id) AS notes,
|
||||||
|
(SELECT COUNT(*) FROM reading_history rh WHERE rh.media_item_id = mi.id) AS history,
|
||||||
|
(SELECT COUNT(*) FROM collection_items ci WHERE ci.media_item_id = mi.id) AS collections
|
||||||
|
FROM media_items mi
|
||||||
|
WHERE (mi.library_id, mi.file_path) IN (
|
||||||
|
SELECT library_id, file_path FROM media_items
|
||||||
|
GROUP BY library_id, file_path HAVING COUNT(*) > 1
|
||||||
|
)
|
||||||
|
ORDER BY mi.library_id, mi.file_path, mi.id;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- HELPER FUNCTIONS (also defined by schema.sql; CREATE OR REPLACE keeps them in sync)
|
||||||
|
-- ======================================================================
|
||||||
|
|
||||||
|
-- Move every child row that points at p_source so it points at p_target,
|
||||||
|
-- deleting any source rows that would violate a UNIQUE constraint on the
|
||||||
|
-- target. Idempotent; no-op when p_target = p_source.
|
||||||
|
CREATE OR REPLACE FUNCTION reparent_media_item_children(p_target UUID, p_source UUID)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF p_target IS NULL OR p_source IS NULL OR p_target = p_source THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- reading_progress (UNIQUE media_item_id, user_id)
|
||||||
|
DELETE FROM reading_progress
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND user_id IN (SELECT user_id FROM reading_progress WHERE media_item_id = p_target);
|
||||||
|
UPDATE reading_progress SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
-- reading_speed (UNIQUE user_id, media_item_id)
|
||||||
|
DELETE FROM reading_speed
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND user_id IN (SELECT user_id FROM reading_speed WHERE media_item_id = p_target);
|
||||||
|
UPDATE reading_speed SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
-- media_ratings (UNIQUE media_item_id, user_id)
|
||||||
|
DELETE FROM media_ratings
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND user_id IN (SELECT user_id FROM media_ratings WHERE media_item_id = p_target);
|
||||||
|
UPDATE media_ratings SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
-- media_bookmarks (UNIQUE media_item_id, user_id, title)
|
||||||
|
DELETE FROM media_bookmarks
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND (user_id, title) IN (SELECT user_id, title FROM media_bookmarks WHERE media_item_id = p_target);
|
||||||
|
UPDATE media_bookmarks SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
-- media_item_formats (UNIQUE media_item_id, format_type)
|
||||||
|
DELETE FROM media_item_formats
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND format_type IN (SELECT format_type FROM media_item_formats WHERE media_item_id = p_target);
|
||||||
|
UPDATE media_item_formats SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
-- collection_items (UNIQUE collection_id, media_item_id)
|
||||||
|
DELETE FROM collection_items
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND collection_id IN (SELECT collection_id FROM collection_items WHERE media_item_id = p_target);
|
||||||
|
UPDATE collection_items SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
-- kobo_shelves (UNIQUE device_id, media_item_id)
|
||||||
|
DELETE FROM kobo_shelves
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND device_id IN (SELECT device_id FROM kobo_shelves WHERE media_item_id = p_target);
|
||||||
|
UPDATE kobo_shelves SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
-- panel_data (UNIQUE media_item_id, page_number)
|
||||||
|
DELETE FROM panel_data
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND page_number IN (SELECT page_number FROM panel_data WHERE media_item_id = p_target);
|
||||||
|
UPDATE panel_data SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
-- processing_issues (UNIQUE media_item_id, issue_type)
|
||||||
|
DELETE FROM processing_issues
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND issue_type IN (SELECT issue_type FROM processing_issues WHERE media_item_id = p_target);
|
||||||
|
UPDATE processing_issues SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
-- device_file_aliases (UNIQUE device_id, file_path) — paths may collide
|
||||||
|
DELETE FROM device_file_aliases
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND (device_id, file_path) IN (SELECT device_id, file_path FROM device_file_aliases WHERE media_item_id = p_target);
|
||||||
|
UPDATE device_file_aliases SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
-- Tables whose UNIQUE keys do not include media_item_id: plain re-parent.
|
||||||
|
UPDATE device_catalogs SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
UPDATE kobo_entitlements SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
UPDATE media_highlights SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
UPDATE media_notes SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
UPDATE reading_history SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
UPDATE sync_conflicts SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
UPDATE sync_queue SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Collapse every (library_id, file_path) group into a single row.
|
||||||
|
-- Survivor = the row with the most user data; ties broken by lowest id.
|
||||||
|
CREATE OR REPLACE FUNCTION dedup_media_items_by_path() RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
g RECORD;
|
||||||
|
v_surv UUID;
|
||||||
|
v_loser UUID;
|
||||||
|
BEGIN
|
||||||
|
FOR g IN
|
||||||
|
SELECT library_id, file_path
|
||||||
|
FROM media_items
|
||||||
|
GROUP BY library_id, file_path
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
LOOP
|
||||||
|
SELECT mi.id INTO v_surv
|
||||||
|
FROM media_items mi
|
||||||
|
WHERE mi.library_id = g.library_id AND mi.file_path = g.file_path
|
||||||
|
ORDER BY
|
||||||
|
((SELECT COUNT(*) FROM reading_progress rp WHERE rp.media_item_id = mi.id)
|
||||||
|
+ (SELECT COUNT(*) FROM media_highlights mh WHERE mh.media_item_id = mi.id)
|
||||||
|
+ (SELECT COUNT(*) FROM media_bookmarks mb WHERE mb.media_item_id = mi.id)
|
||||||
|
+ (SELECT COUNT(*) FROM media_notes mn WHERE mn.media_item_id = mi.id)
|
||||||
|
+ (SELECT COUNT(*) FROM reading_history rh WHERE rh.media_item_id = mi.id)
|
||||||
|
+ (SELECT COUNT(*) FROM collection_items ci WHERE ci.media_item_id = mi.id)) DESC,
|
||||||
|
mi.id ASC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
FOR v_loser IN
|
||||||
|
SELECT id FROM media_items
|
||||||
|
WHERE library_id = g.library_id AND file_path = g.file_path AND id <> v_surv
|
||||||
|
ORDER BY id
|
||||||
|
LOOP
|
||||||
|
PERFORM reparent_media_item_children(v_surv, v_loser);
|
||||||
|
DELETE FROM media_items WHERE id = v_loser;
|
||||||
|
END LOOP;
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ======================================================================
|
||||||
|
-- CLEANUP: collapse path duplicates (required before the UNIQUE constraint)
|
||||||
|
-- ======================================================================
|
||||||
|
|
||||||
|
\echo '=== Collapsing path duplicates ===';
|
||||||
|
BEGIN;
|
||||||
|
SELECT dedup_media_items_by_path();
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
\echo '=== Done. Remaining PATH duplicates (should be empty): ===';
|
||||||
|
SELECT library_id, file_path, COUNT(*) AS dupes
|
||||||
|
FROM media_items
|
||||||
|
GROUP BY library_id, file_path
|
||||||
|
HAVING COUNT(*) > 1;
|
||||||
|
|
||||||
|
\echo 'NOTE: CONTENT duplicates (same hash, different paths) are NOT auto-deleted.'
|
||||||
|
\echo ' They do not violate the UNIQUE constraint. Review the dry-run output'
|
||||||
|
\echo ' above and merge them manually if desired.'
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// HashConflictItemData is one copy in a conflict group.
|
||||||
|
type HashConflictItemData struct {
|
||||||
|
ID string
|
||||||
|
Title string
|
||||||
|
Author string
|
||||||
|
FilePath string
|
||||||
|
FileSize int64
|
||||||
|
UsageSummary string
|
||||||
|
HasReadingData bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashConflictData is one pending conflict group.
|
||||||
|
type HashConflictData struct {
|
||||||
|
ID string
|
||||||
|
LibraryName string
|
||||||
|
SHA256 string
|
||||||
|
SHAShort string
|
||||||
|
CreatedAt string
|
||||||
|
Items []HashConflictItemData
|
||||||
|
}
|
||||||
|
|
||||||
|
templ HashConflictCard(conflict HashConflictData) {
|
||||||
|
<div class="card p-6" id={ "conflict-" + conflict.ID }>
|
||||||
|
<div class="mb-4 flex items-start justify-between gap-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="mb-1 flex items-center gap-2">
|
||||||
|
@Icon("copy", "h-5 w-5 shrink-0")
|
||||||
|
<h4 class="text-lg font-semibold" style="color: var(--text-primary)">Duplicate content</h4>
|
||||||
|
<span class="badge status-pending">{ fmt.Sprintf("%d copies", len(conflict.Items)) }</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">
|
||||||
|
Library: <span class="font-medium">{ conflict.LibraryName }</span>
|
||||||
|
<span class="mx-2">·</span>
|
||||||
|
SHA-256: <code class="text-xs">{ conflict.SHAShort }</code>
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs" style="color: var(--text-secondary)">
|
||||||
|
These files are byte-identical. Keep one copy (reading data from the others is merged in), or keep both if the duplicates are intentional.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="shrink-0">
|
||||||
|
<button
|
||||||
|
hx-post={ "/api/admin/hash-conflicts/" + conflict.ID + "/resolve" }
|
||||||
|
hx-vals='{"action": "keep_all"}'
|
||||||
|
hx-target={ "#conflict-" + conflict.ID }
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-confirm="Keep all copies and stop flagging this group?"
|
||||||
|
class="btn btn-secondary text-sm"
|
||||||
|
>
|
||||||
|
@Icon("check-circle", "h-4 w-4")
|
||||||
|
Keep both
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
for _, item := range conflict.Items {
|
||||||
|
@HashConflictItemCardWrapper(item, conflict.ID)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashConflictItemCardWrapper renders an item card with its parent conflict ID
|
||||||
|
// for the resolve endpoint targeting.
|
||||||
|
templ HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) {
|
||||||
|
<div class="flex items-start justify-between gap-4 rounded-lg border p-4" style="border-color: var(--border);">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="font-medium truncate" style="color: var(--text-primary)">{ item.Title }</p>
|
||||||
|
if item.Author != "" {
|
||||||
|
<p class="text-sm truncate" style="color: var(--text-secondary)">{ item.Author }</p>
|
||||||
|
}
|
||||||
|
<p class="mt-1 text-xs break-all" style="color: var(--text-secondary)">{ item.FilePath }</p>
|
||||||
|
<p class="mt-1 text-xs" style="color: var(--text-secondary)">
|
||||||
|
{ fmt.Sprintf("%.1f MB", float64(item.FileSize)/(1024*1024)) }
|
||||||
|
if item.HasReadingData {
|
||||||
|
<span class="ml-2 font-medium" style="color: var(--accent);">{ item.UsageSummary }</span>
|
||||||
|
} else {
|
||||||
|
<span class="ml-2">{ item.UsageSummary }</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="shrink-0">
|
||||||
|
<button
|
||||||
|
hx-post={ "/api/admin/hash-conflicts/" + conflictID + "/resolve" }
|
||||||
|
hx-vals={ fmt.Sprintf(`{"action": "keep", "keep_uuid": "%s"}`, item.ID) }
|
||||||
|
hx-target={ "#conflict-" + conflictID }
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-confirm="Keep this copy and merge the other copy's reading data into it?"
|
||||||
|
class="btn btn-secondary text-sm"
|
||||||
|
>
|
||||||
|
@Icon("check", "h-4 w-4")
|
||||||
|
Keep this copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashConflictResolved is swapped in place of a card after resolution.
|
||||||
|
templ AdminHashConflicts(user User, conflicts []HashConflictData) {
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<title>Hash Conflicts - Bookhoard</title>
|
||||||
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
|
</head>
|
||||||
|
<body class={ "theme-" + user.Theme }>
|
||||||
|
@Header(user, "/admin/hash-conflicts")
|
||||||
|
<main class="p-8">
|
||||||
|
<div class="mx-auto max-w-4xl">
|
||||||
|
<div class="mb-8">
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 flex items-center gap-3">
|
||||||
|
<span class="grid h-10 w-10 place-items-center rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
|
@Icon("copy", "h-5 w-5")
|
||||||
|
</span>
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Hash Conflicts</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">Books with identical content stored at more than one path</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
if len(conflicts) == 0 {
|
||||||
|
<div class="card p-8 text-center">
|
||||||
|
<span class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
|
@Icon("check-circle", "h-6 w-6")
|
||||||
|
</span>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">No content duplicates detected.</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="space-y-4">
|
||||||
|
for _, conflict := range conflicts {
|
||||||
|
@HashConflictCard(conflict)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
|
// templ: version: v0.3.1020
|
||||||
|
package templates
|
||||||
|
|
||||||
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
|
|
||||||
|
import "github.com/a-h/templ"
|
||||||
|
import templruntime "github.com/a-h/templ/runtime"
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// HashConflictItemData is one copy in a conflict group.
|
||||||
|
type HashConflictItemData struct {
|
||||||
|
ID string
|
||||||
|
Title string
|
||||||
|
Author string
|
||||||
|
FilePath string
|
||||||
|
FileSize int64
|
||||||
|
UsageSummary string
|
||||||
|
HasReadingData bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashConflictData is one pending conflict group.
|
||||||
|
type HashConflictData struct {
|
||||||
|
ID string
|
||||||
|
LibraryName string
|
||||||
|
SHA256 string
|
||||||
|
SHAShort string
|
||||||
|
CreatedAt string
|
||||||
|
Items []HashConflictItemData
|
||||||
|
}
|
||||||
|
|
||||||
|
func HashConflictCard(conflict HashConflictData) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var1 == nil {
|
||||||
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"card p-6\" id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var2 string
|
||||||
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("conflict-" + conflict.ID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 27, Col: 53}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"><div class=\"mb-4 flex items-start justify-between gap-4\"><div class=\"min-w-0\"><div class=\"mb-1 flex items-center gap-2\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("copy", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<h4 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Duplicate content</h4><span class=\"badge status-pending\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var3 string
|
||||||
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d copies", len(conflict.Items)))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 33, Col: 87}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</span></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Library: <span class=\"font-medium\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var4 string
|
||||||
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.LibraryName)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 36, Col: 62}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span> <span class=\"mx-2\">·</span> SHA-256: <code class=\"text-xs\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var5 string
|
||||||
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.SHAShort)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 38, Col: 55}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</code></p><p class=\"mt-1 text-xs\" style=\"color: var(--text-secondary)\">These files are byte-identical. Keep one copy (reading data from the others is merged in), or keep both if the duplicates are intentional.</p></div><div class=\"shrink-0\"><button hx-post=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var6 string
|
||||||
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/admin/hash-conflicts/" + conflict.ID + "/resolve")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 46, Col: 70}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" hx-vals='{\"action\": \"keep_all\"}' hx-target=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var7 string
|
||||||
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("#conflict-" + conflict.ID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 48, Col: 43}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" hx-swap=\"outerHTML\" hx-confirm=\"Keep all copies and stop flagging this group?\" class=\"btn btn-secondary text-sm\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("check-circle", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Keep both</button></div></div><div class=\"space-y-3\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, item := range conflict.Items {
|
||||||
|
templ_7745c5c3_Err = HashConflictItemCardWrapper(item, conflict.ID).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashConflictItemCardWrapper renders an item card with its parent conflict ID
|
||||||
|
// for the resolve endpoint targeting.
|
||||||
|
func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var8 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var8 == nil {
|
||||||
|
templ_7745c5c3_Var8 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"flex items-start justify-between gap-4 rounded-lg border p-4\" style=\"border-color: var(--border);\"><div class=\"min-w-0 flex-1\"><p class=\"font-medium truncate\" style=\"color: var(--text-primary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 string
|
||||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 71, Col: 82}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if item.Author != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<p class=\"text-sm truncate\" style=\"color: var(--text-secondary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var10 string
|
||||||
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 73, Col: 82}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<p class=\"mt-1 text-xs break-all\" style=\"color: var(--text-secondary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var11 string
|
||||||
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.FilePath)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 75, Col: 89}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</p><p class=\"mt-1 text-xs\" style=\"color: var(--text-secondary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var12 string
|
||||||
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f MB", float64(item.FileSize)/(1024*1024)))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 77, Col: 64}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if item.HasReadingData {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<span class=\"ml-2 font-medium\" style=\"color: var(--accent);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var13 string
|
||||||
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.UsageSummary)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 79, Col: 85}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<span class=\"ml-2\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var14 string
|
||||||
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(item.UsageSummary)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 81, Col: 43}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</p></div><div class=\"shrink-0\"><button hx-post=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var15 string
|
||||||
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/admin/hash-conflicts/" + conflictID + "/resolve")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 87, Col: 68}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" hx-vals=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var16 string
|
||||||
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf(`{"action": "keep", "keep_uuid": "%s"}`, item.ID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 88, Col: 75}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" hx-target=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var17 string
|
||||||
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("#conflict-" + conflictID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 89, Col: 41}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" hx-swap=\"outerHTML\" hx-confirm=\"Keep this copy and merge the other copy's reading data into it?\" class=\"btn btn-secondary text-sm\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("check", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "Keep this copy</button></div></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashConflictResolved is swapped in place of a card after resolution.
|
||||||
|
func AdminHashConflicts(user User, conflicts []HashConflictData) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var18 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var18 == nil {
|
||||||
|
templ_7745c5c3_Var18 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Hash Conflicts - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var19 = []any{"theme-" + user.Theme}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var19...)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<body class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var20 string
|
||||||
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var19).String())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Header(user, "/admin/hash-conflicts").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<main class=\"p-8\"><div class=\"mx-auto max-w-4xl\"><div class=\"mb-8\"><div><div class=\"mb-1 flex items-center gap-3\"><span class=\"grid h-10 w-10 place-items-center rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("copy", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Hash Conflicts</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Books with identical content stored at more than one path</p></div></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if len(conflicts) == 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div class=\"card p-8 text-center\"><span class=\"mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("check-circle", "h-6 w-6").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</span><p class=\"text-sm\" style=\"color: var(--text-secondary)\">No content duplicates detected.</p></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<div class=\"space-y-4\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, conflict := range conflicts {
|
||||||
|
templ_7745c5c3_Err = HashConflictCard(conflict).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</div></main></body></html>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = templruntime.GeneratedTemplate
|
||||||
@@ -159,6 +159,10 @@ templ Header(user User, currentPath string) {
|
|||||||
@Icon("library", "h-5 w-5 shrink-0")
|
@Icon("library", "h-5 w-5 shrink-0")
|
||||||
<span>Libraries</span>
|
<span>Libraries</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/hash-conflicts" class={ activeClass(currentPath, "/admin/hash-conflicts") }>
|
||||||
|
@Icon("copy", "h-5 w-5 shrink-0")
|
||||||
|
<span>Hash Conflicts</span>
|
||||||
|
</a>
|
||||||
<a href="/admin/users" class={ activeClass(currentPath, "/admin/users") }>
|
<a href="/admin/users" class={ activeClass(currentPath, "/admin/users") }>
|
||||||
@Icon("users", "h-5 w-5 shrink-0")
|
@Icon("users", "h-5 w-5 shrink-0")
|
||||||
<span>Users</span>
|
<span>Users</span>
|
||||||
|
|||||||
+64
-34
@@ -494,12 +494,12 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var28 = []any{activeClass(currentPath, "/admin/users")}
|
var templ_7745c5c3_Var28 = []any{activeClass(currentPath, "/admin/hash-conflicts")}
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var28...)
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var28...)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<a href=\"/admin/users\" class=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<a href=\"/admin/hash-conflicts\" class=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -516,20 +516,20 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = Icon("users", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Icon("copy", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<span>Users</span></a> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<span>Hash Conflicts</span></a> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var30 = []any{activeClass(currentPath, "/admin/settings")}
|
var templ_7745c5c3_Var30 = []any{activeClass(currentPath, "/admin/users")}
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var30...)
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var30...)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<a href=\"/admin/settings\" class=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<a href=\"/admin/users\" class=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -546,16 +546,46 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("users", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span>Users</span></a> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var32 = []any{activeClass(currentPath, "/admin/settings")}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var32...)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<a href=\"/admin/settings\" class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var33 string
|
||||||
|
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var32).String())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
templ_7745c5c3_Err = Icon("gear", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Icon("gear", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span>Settings</span></a></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<span>Settings</span></a></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</div></aside><!-- Topbar --><header class=\"app-topbar\"><div class=\"flex items-center gap-3 px-4 sm:px-6 h-full\"><button type=\"button\" class=\"app-mobile-only icon-btn\" @click=\"mobileMenuOpen = true\" aria-label=\"Open menu\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</div></aside><!-- Topbar --><header class=\"app-topbar\"><div class=\"flex items-center gap-3 px-4 sm:px-6 h-full\"><button type=\"button\" class=\"app-mobile-only icon-btn\" @click=\"mobileMenuOpen = true\" aria-label=\"Open menu\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -563,7 +593,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</button><div class=\"relative flex-1 max-w-xl\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</button><div class=\"relative flex-1 max-w-xl\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -571,7 +601,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</span> <input type=\"text\" id=\"header-search\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\"></div></div></header><script type=\"module\" src=\"/static/main.js\"></script></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</span> <input type=\"text\" id=\"header-search\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\"></div></div></header><script type=\"module\" src=\"/static/main.js\"></script></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -596,12 +626,12 @@ func SidebarUserMenu(user User) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var32 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var34 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var32 == nil {
|
if templ_7745c5c3_Var34 == nil {
|
||||||
templ_7745c5c3_Var32 = templ.NopComponent
|
templ_7745c5c3_Var34 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'user' ? null : 'user'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'user' ? null : 'user'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -609,20 +639,20 @@ func SidebarUserMenu(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</span> <span class=\"flex-1 text-left truncate\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</span> <span class=\"flex-1 text-left truncate\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var33 string
|
var templ_7745c5c3_Var35 string
|
||||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 216, Col: 58}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 220, Col: 58}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'user' }\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'user' }\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -630,7 +660,7 @@ func SidebarUserMenu(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</span></button><div x-show=\"openPanel === 'user'\" x-cloak x-transition class=\"mt-1 space-y-0.5 pl-1\"><a href=\"/profile\" class=\"flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</span></button><div x-show=\"openPanel === 'user'\" x-cloak x-transition class=\"mt-1 space-y-0.5 pl-1\"><a href=\"/profile\" class=\"flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -638,7 +668,7 @@ func SidebarUserMenu(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<span>Profile</span></a> <button type=\"button\" @click=\"logout()\" class=\"w-full flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<span>Profile</span></a> <button type=\"button\" @click=\"logout()\" class=\"w-full flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -646,7 +676,7 @@ func SidebarUserMenu(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<span>Logout</span></button></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<span>Logout</span></button></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -672,12 +702,12 @@ func SidebarSignIn(currentPath string) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var34 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var36 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var34 == nil {
|
if templ_7745c5c3_Var36 == nil {
|
||||||
templ_7745c5c3_Var34 = templ.NopComponent
|
templ_7745c5c3_Var36 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'signin' ? null : 'signin'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'signin' ? null : 'signin'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -685,7 +715,7 @@ func SidebarSignIn(currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</span> <span class=\"flex-1 text-left\">Sign in</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'signin' }\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</span> <span class=\"flex-1 text-left\">Sign in</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'signin' }\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -693,20 +723,20 @@ func SidebarSignIn(currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</span></button><div x-show=\"openPanel === 'signin'\" x-cloak x-transition class=\"mt-1 px-1\"><form hx-post=\"/api/auth/login\" hx-target=\"#login-result\" hx-swap=\"innerHTML\" class=\"space-y-2\"><input type=\"hidden\" name=\"redirect\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</span></button><div x-show=\"openPanel === 'signin'\" x-cloak x-transition class=\"mt-1 px-1\"><form hx-post=\"/api/auth/login\" hx-target=\"#login-result\" hx-swap=\"innerHTML\" class=\"space-y-2\"><input type=\"hidden\" name=\"redirect\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var35 string
|
var templ_7745c5c3_Var37 string
|
||||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 264, Col: 60}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 268, Col: 60}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\"> <input type=\"text\" name=\"login\" class=\"input py-1.5 text-sm\" placeholder=\"Email or username\" required> <input type=\"password\" name=\"password\" class=\"input py-1.5 text-sm\" placeholder=\"Password\" required> <button type=\"submit\" class=\"btn btn-primary w-full py-1.5 text-sm\">Sign In</button></form><div id=\"login-result\"></div><a href=\"/register\" class=\"block text-center text-xs mt-2 hover:underline\" style=\"color: var(--text-secondary);\">Create an account</a></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\"> <input type=\"text\" name=\"login\" class=\"input py-1.5 text-sm\" placeholder=\"Email or username\" required> <input type=\"password\" name=\"password\" class=\"input py-1.5 text-sm\" placeholder=\"Password\" required> <button type=\"submit\" class=\"btn btn-primary w-full py-1.5 text-sm\">Sign In</button></form><div id=\"login-result\"></div><a href=\"/register\" class=\"block text-center text-xs mt-2 hover:underline\" style=\"color: var(--text-secondary);\">Create an account</a></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,8 +153,13 @@ templ ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<!-- Pan/Select mode (PDF only) -->
|
<!-- Pan/Select mode (PDF only) -->
|
||||||
<button x-show="isPDF" @click="toggleInteractionMode()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Pan/Select Mode" aria-label="Toggle pan/select mode">
|
<button x-show="isPDF" @click="toggleInteractionMode()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" :class="interactionMode === 'pan' ? 'bg-blue-600 hover:bg-blue-700' : ''" :title="interactionMode === 'pan' ? 'Pan mode: drag anywhere to move. Click for Smart select.' : 'Smart select: drag text to select, drag blank to pan (Shift = force pan). Click for Pan mode.'" :aria-label="interactionMode === 'pan' ? 'Pan mode' : 'Smart select mode'">
|
||||||
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
|
<!-- Smart select (default): text cursor -->
|
||||||
|
<svg x-show="interactionMode !== 'pan'" class="reader-icon" width="20" height="20" aria-hidden="true">
|
||||||
|
<path d="M 10 3 L 10 17 M 7 5 L 10 4 L 13 5 M 7 15 L 10 16 L 13 15"></path>
|
||||||
|
</svg>
|
||||||
|
<!-- Force pan: move arrow -->
|
||||||
|
<svg x-show="interactionMode === 'pan'" class="reader-icon" width="20" height="20" aria-hidden="true">
|
||||||
<path d="M 5 3 v 12 M 5 15 l -2 2 M 5 15 l 2 2 M 5 3 l 3 3"></path>
|
<path d="M 5 3 v 12 M 5 15 l -2 2 M 5 15 l 2 2 M 5 3 l 3 3"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -306,7 +311,7 @@ templ ReaderSettingsPanel() {
|
|||||||
<div class="mb-6" x-show="isFixedLayout">
|
<div class="mb-6" x-show="isFixedLayout">
|
||||||
<h3 class="font-semibold mb-2">Navigation</h3>
|
<h3 class="font-semibold mb-2">Navigation</h3>
|
||||||
<label class="flex items-center mb-2">
|
<label class="flex items-center mb-2">
|
||||||
<input type="checkbox" name="double_page_spread" class="mr-2"/>
|
<input type="checkbox" name="double_page_spread" x-model="doublePageSpread" @change="applyDoublePageSpread()" class="mr-2"/>
|
||||||
Double Page Spread
|
Double Page Spread
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -371,6 +371,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
isPDF: false,
|
isPDF: false,
|
||||||
interactionMode: "select" as string,
|
interactionMode: "select" as string,
|
||||||
magnifierEnabled: false,
|
magnifierEnabled: false,
|
||||||
|
doublePageSpread: true as boolean,
|
||||||
progressText: "",
|
progressText: "",
|
||||||
progressLabel: "",
|
progressLabel: "",
|
||||||
progressMain: "",
|
progressMain: "",
|
||||||
@@ -454,6 +455,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.readingFont = this.settings.reading_font || "literata";
|
this.readingFont = this.settings.reading_font || "literata";
|
||||||
this.fontSize = this.settings.font_size || 18;
|
this.fontSize = this.settings.font_size || 18;
|
||||||
this.lineHeight = this.settings.line_height || 1.6;
|
this.lineHeight = this.settings.line_height || 1.6;
|
||||||
|
this.doublePageSpread = this.settings.double_page_spread ?? true;
|
||||||
if (this.settings.reading_mode) {
|
if (this.settings.reading_mode) {
|
||||||
this.readingMode = this.settings.reading_mode;
|
this.readingMode = this.settings.reading_mode;
|
||||||
} else {
|
} else {
|
||||||
@@ -492,6 +494,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.zoomPercent = this.renderer.zoomPercent;
|
this.zoomPercent = this.renderer.zoomPercent;
|
||||||
});
|
});
|
||||||
this.computeFixedLayoutChapterBoundaries();
|
this.computeFixedLayoutChapterBoundaries();
|
||||||
|
this.applyDoublePageSpread();
|
||||||
} else {
|
} else {
|
||||||
this.renderer.setStyles?.(this.buildCSS());
|
this.renderer.setStyles?.(this.buildCSS());
|
||||||
}
|
}
|
||||||
@@ -692,6 +695,14 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.renderer.setAttribute("interaction-mode", next);
|
this.renderer.setAttribute("interaction-mode", next);
|
||||||
this.interactionMode = next;
|
this.interactionMode = next;
|
||||||
},
|
},
|
||||||
|
applyDoublePageSpread() {
|
||||||
|
if (!this.isFixedLayout || !this.renderer) return;
|
||||||
|
this.renderer.setAttribute(
|
||||||
|
"spread",
|
||||||
|
this.doublePageSpread ? "auto" : "none",
|
||||||
|
);
|
||||||
|
saveSettings({ double_page_spread: this.doublePageSpread });
|
||||||
|
},
|
||||||
goLeft() {
|
goLeft() {
|
||||||
this.view?.goLeft?.();
|
this.view?.goLeft?.();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function getDefaultSettings(): ReaderSettings {
|
|||||||
font_size: 18,
|
font_size: 18,
|
||||||
line_height: 1.6,
|
line_height: 1.6,
|
||||||
margin_width: 20,
|
margin_width: 20,
|
||||||
double_page_spread: false,
|
double_page_spread: true,
|
||||||
reading_direction: "ltr",
|
reading_direction: "ltr",
|
||||||
hardware_acceleration: true,
|
hardware_acceleration: true,
|
||||||
panel_layout: {
|
panel_layout: {
|
||||||
|
|||||||
Reference in New Issue
Block a user