diff --git a/database/schema/schema.sql b/database/schema/schema.sql index 9385dd8c..4cb8e799 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -1353,6 +1353,8 @@ CREATE TABLE IF NOT EXISTS unlinked_books ( CREATE INDEX IF NOT EXISTS idx_unlinked_books_device ON unlinked_books(device_id); CREATE INDEX IF NOT EXISTS idx_unlinked_books_content_id ON unlinked_books(content_id); +-- One queue row per (device, document): the sync ingest upserts on this pair. +CREATE UNIQUE INDEX IF NOT EXISTS idx_unlinked_books_device_content ON unlinked_books(device_id, content_id); CREATE INDEX IF NOT EXISTS idx_unlinked_books_resolved ON unlinked_books(resolved); -- Create saved_filters for users to be able to filter different parts of the application diff --git a/internal/database/querier.go b/internal/database/querier.go index 91f7eb47..6cdf8063 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -48,6 +48,8 @@ type Querier interface { CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error) // Count unlinked books for a device CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error) + // Banner count: unresolved queue rows across the caller's own devices. + CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error) CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error) CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error) // COLLECTIONS QUERIES @@ -469,6 +471,8 @@ type Querier interface { ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error) // List unresolved unlinked books with pagination ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error) + // Page list: unresolved queue rows for the caller's own devices. + ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error) ListUsers(ctx context.Context) ([]ListUsersRow, error) // First consecutive scan that cannot find the file on disk. MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error @@ -607,6 +611,9 @@ type Querier interface { UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error) UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error) UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error) + // Ingest: insert or refresh the queue row for a device file that could not + // be identified. One row per (device, content_id); re-syncs bump last_seen. + UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBookParams) (UnlinkedBooks, error) } var _ Querier = (*Queries)(nil) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index adb7c8eb..6d62bc73 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -332,6 +332,21 @@ func (q *Queries) CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) return count, err } +const CountUnresolvedUnlinkedBooksForUser = `-- name: CountUnresolvedUnlinkedBooksForUser :one +SELECT COUNT(*) as count +FROM unlinked_books ub +JOIN devices d ON ub.device_id = d.id +WHERE d.user_id = $1 AND ub.resolved = false +` + +// Banner count: unresolved queue rows across the caller's own devices. +func (q *Queries) CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error) { + row := q.db.QueryRow(ctx, CountUnresolvedUnlinkedBooksForUser, userID) + var count int64 + err := row.Scan(&count) + return count, err +} + const CountUserDevices = `-- name: CountUserDevices :one SELECT COUNT(*) FROM devices WHERE user_id = $1 ` @@ -11365,6 +11380,69 @@ func (q *Queries) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnres return items, nil } +const ListUnresolvedUnlinkedBooksForUser = `-- name: ListUnresolvedUnlinkedBooksForUser :many +SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, d.device_name, d.device_type +FROM unlinked_books ub +JOIN devices d ON ub.device_id = d.id +WHERE d.user_id = $1 AND ub.resolved = false +ORDER BY ub.last_seen_at DESC +` + +type ListUnresolvedUnlinkedBooksForUserRow struct { + ID pgtype.UUID `db:"id" json:"id"` + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + ContentID string `db:"content_id" json:"content_id"` + FilePath pgtype.Text `db:"file_path" json:"file_path"` + Title pgtype.Text `db:"title" json:"title"` + Author pgtype.Text `db:"author" json:"author"` + ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"` + Resolved pgtype.Bool `db:"resolved" json:"resolved"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"` + ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"` + LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + DeviceName string `db:"device_name" json:"device_name"` + DeviceType string `db:"device_type" json:"device_type"` +} + +// Page list: unresolved queue rows for the caller's own devices. +func (q *Queries) ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error) { + rows, err := q.db.Query(ctx, ListUnresolvedUnlinkedBooksForUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListUnresolvedUnlinkedBooksForUserRow{} + for rows.Next() { + var i ListUnresolvedUnlinkedBooksForUserRow + if err := rows.Scan( + &i.ID, + &i.DeviceID, + &i.ContentID, + &i.FilePath, + &i.Title, + &i.Author, + &i.ConfidenceScore, + &i.Resolved, + &i.MediaItemID, + &i.ResolvedAt, + &i.ResolutionMethod, + &i.LastSeenAt, + &i.CreatedAt, + &i.DeviceName, + &i.DeviceType, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const ListUsers = `-- name: ListUsers :many SELECT u.id, @@ -15382,3 +15460,54 @@ func (q *Queries) UpsertSystemSetting(ctx context.Context, arg UpsertSystemSetti ) return i, err } + +const UpsertUnlinkedBook = `-- name: UpsertUnlinkedBook :one +INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, confidence_score) +VALUES ($1, $2, $3, $4, $5, $6) +ON CONFLICT (device_id, content_id) DO UPDATE +SET file_path = EXCLUDED.file_path, + title = EXCLUDED.title, + author = EXCLUDED.author, + confidence_score = EXCLUDED.confidence_score, + last_seen_at = NOW() +RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at +` + +type UpsertUnlinkedBookParams struct { + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + ContentID string `db:"content_id" json:"content_id"` + FilePath pgtype.Text `db:"file_path" json:"file_path"` + Title pgtype.Text `db:"title" json:"title"` + Author pgtype.Text `db:"author" json:"author"` + ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"` +} + +// Ingest: insert or refresh the queue row for a device file that could not +// be identified. One row per (device, content_id); re-syncs bump last_seen. +func (q *Queries) UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBookParams) (UnlinkedBooks, error) { + row := q.db.QueryRow(ctx, UpsertUnlinkedBook, + arg.DeviceID, + arg.ContentID, + arg.FilePath, + arg.Title, + arg.Author, + arg.ConfidenceScore, + ) + var i UnlinkedBooks + err := row.Scan( + &i.ID, + &i.DeviceID, + &i.ContentID, + &i.FilePath, + &i.Title, + &i.Author, + &i.ConfidenceScore, + &i.Resolved, + &i.MediaItemID, + &i.ResolvedAt, + &i.ResolutionMethod, + &i.LastSeenAt, + &i.CreatedAt, + ) + return i, err +} diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 61384691..37c0d344 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -2619,6 +2619,34 @@ WHERE ub.resolved = false ORDER BY ub.last_seen_at DESC LIMIT $1 OFFSET $2; +-- Ingest: insert or refresh the queue row for a device file that could not +-- be identified. One row per (device, content_id); re-syncs bump last_seen. +-- name: UpsertUnlinkedBook :one +INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, confidence_score) +VALUES ($1, $2, $3, $4, $5, $6) +ON CONFLICT (device_id, content_id) DO UPDATE +SET file_path = EXCLUDED.file_path, + title = EXCLUDED.title, + author = EXCLUDED.author, + confidence_score = EXCLUDED.confidence_score, + last_seen_at = NOW() +RETURNING *; + +-- Banner count: unresolved queue rows across the caller's own devices. +-- name: CountUnresolvedUnlinkedBooksForUser :one +SELECT COUNT(*) as count +FROM unlinked_books ub +JOIN devices d ON ub.device_id = d.id +WHERE d.user_id = $1 AND ub.resolved = false; + +-- Page list: unresolved queue rows for the caller's own devices. +-- name: ListUnresolvedUnlinkedBooksForUser :many +SELECT ub.*, d.device_name, d.device_type +FROM unlinked_books ub +JOIN devices d ON ub.device_id = d.id +WHERE d.user_id = $1 AND ub.resolved = false +ORDER BY ub.last_seen_at DESC; + -- ============================================ -- CAROUSEL-STYLE DASHBOARD -- ============================================ diff --git a/internal/handlers/koreader.go b/internal/handlers/koreader.go index 3af38369..37b3d646 100644 --- a/internal/handlers/koreader.go +++ b/internal/handlers/koreader.go @@ -451,6 +451,10 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error { for _, book := range req.Books { mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, pgUserID, book) if !mediaItemID.Valid { + // Unidentified file: queue it for manual identification + // (unlinked-books page) instead of silently dropping the + // file's progress. One row per (device, content id). + h.queueUnlinkedBook(c, device.ID, book) bookResults = append(bookResults, KOReaderBookSyncResult{ SHA256: book.SHA256, Synced: false, @@ -498,9 +502,47 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error { }) } -func (h *KOReaderHandler) resolveBookToMediaItem(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, book KOReaderBookProgress) (pgtype.UUID, float64) { +// queueUnlinkedBook records a device file that could not be identified, so +// the user can resolve it on the unlinked-books page. One row per +// (device, content id); re-syncs refresh the row instead of duplicating it. +// Best-effort: queueing failures never fail the sync response. +func (h *KOReaderHandler) queueUnlinkedBook(c *echo.Context, deviceID pgtype.UUID, book KOReaderBookProgress) { ctx := c.Request().Context() + contentID := book.UUID + if contentID == "" { + contentID = book.SHA256 + } + if contentID == "" { + // Nothing stable to identify the file by — it could never resolve. + return + } + + var title pgtype.Text + if book.Title != "" { + title = pgtype.Text{String: book.Title, Valid: true} + } + var author pgtype.Text + if len(book.Authors) > 0 && book.Authors[0] != "" { + author = pgtype.Text{String: book.Authors[0], Valid: true} + } + var filePath pgtype.Text + if book.FilePath != "" { + filePath = pgtype.Text{String: book.FilePath, Valid: true} + } + + _, _ = h.db.UpsertUnlinkedBook(ctx, database.UpsertUnlinkedBookParams{ + DeviceID: deviceID, + ContentID: contentID, + FilePath: filePath, + Title: title, + Author: author, + ConfidenceScore: pgtype.Float8{Float64: 0, Valid: true}, + }) +} + +func (h *KOReaderHandler) resolveBookToMediaItem(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, book KOReaderBookProgress) (pgtype.UUID, float64) { ctx := c.Request().Context() + // Priority 1: UUID provided (highest confidence - 1.0) if book.UUID != "" { mediaUUID, err := uuid.Parse(book.UUID) diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 3923ee1b..402f4f3d 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -475,8 +475,18 @@ func registerFrontendRoutes(cfg *Config) { } } + userUUID, userUUIDErr := uuid.Parse(user.ID) + if userUUIDErr != nil { + return renderErrorPage(c, "Error loading user", "user_load_error") + } + unlinkedCount, countErr := cfg.Queries.CountUnresolvedUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true}) + if countErr != nil { + log.Printf("CountUnresolvedUnlinkedBooksForUser failed: %v", countErr) + unlinkedCount = 0 + } + var buf bytes.Buffer - err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, week, activeLibType, errorMsg).Render(c.Request().Context(), &buf) + err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, week, activeLibType, errorMsg, int(unlinkedCount)).Render(c.Request().Context(), &buf) if err != nil { return err } @@ -742,6 +752,45 @@ func registerFrontendRoutes(cfg *Config) { // itself here (htmx) whenever the library switcher dispatches // week-refresh, carrying the current #library-select value. Same // resolution rules as the dashboard page (family-lens safe). + // Unlinked-books page: the identification queue for device files the + // sync could not match (personal data — the caller's own devices only). + frontendProtected.GET("/unlinked-books", func(c *echo.Context) error { + user, err := getTemplateUserWithTheme(c, cfg) + if err != nil { + return renderErrorPage(c, "Error loading user", "user_load_error") + } + userUUID, userUUIDErr := uuid.Parse(user.ID) + if userUUIDErr != nil { + return renderErrorPage(c, "Error loading user", "user_load_error") + } + rows, listErr := cfg.Queries.ListUnresolvedUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true}) + if listErr != nil { + log.Printf("ListUnresolvedUnlinkedBooksForUser failed: %v", listErr) + rows = []database.ListUnresolvedUnlinkedBooksForUserRow{} + } + + books := make([]templates.UnlinkedBookData, 0, len(rows)) + for _, r := range rows { + books = append(books, templates.UnlinkedBookData{ + ProgressID: uuid.UUID(r.ID.Bytes).String(), + DeviceID: uuid.UUID(r.DeviceID.Bytes).String(), + DeviceType: r.DeviceType, + DeviceName: r.DeviceName, + FilePath: utils.PgTextString(r.FilePath), + SHA256: r.ContentID, + TitleFromDevice: utils.PgTextString(r.Title), + LastSyncTime: utils.FormatTimeInTimezone(r.LastSeenAt.Time, user.Timezone), + ConfidenceScore: r.ConfidenceScore.Float64, + }) + } + + var buf bytes.Buffer + if renderErr := templates.UnlinkedBooks(user, books).Render(c.Request().Context(), &buf); renderErr != nil { + return renderErr + } + return c.HTML(http.StatusOK, buf.String()) + }) + // Unlinked-book match results partial: the match cascade for one device // file, rendered server-side and scoped to the requester's visible // libraries (absent-from-results) — rendering-ownership: templ renders, diff --git a/templates/dashboard.templ b/templates/dashboard.templ index 4fb28090..3584dce4 100644 --- a/templates/dashboard.templ +++ b/templates/dashboard.templ @@ -5,7 +5,7 @@ import ( "fmt" ) -templ Dashboard(user User, sections []handlers.SectionData, allSections []handlers.SectionData, libData []LibraryData, currentLibraryID string, hiddenCollections []string, itemsPerSection int, week *WeekStatsData, activeLibType string, errorMessage string) { +templ Dashboard(user User, sections []handlers.SectionData, allSections []handlers.SectionData, libData []LibraryData, currentLibraryID string, hiddenCollections []string, itemsPerSection int, week *WeekStatsData, activeLibType string, errorMessage string, unlinkedCount int) {
@@ -23,6 +23,24 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle re-render wipes that container's innerHTML, and anything server-rendered inside it dies on the first library switch. The widget re-fetches itself via htmx instead. --> + if unlinkedCount > 0 { + + } if week != nil {