feat(sync): unlinked-books queue — ingest, page route, dashboard banner
Release / build-and-push (push) Successful in 3m7s
Release / build-and-push (push) Successful in 3m7s
The identification queue for device files the sync cannot match is now live end-to-end. Previously the whole feature was dark: unmatched device files were silently dropped (losing their reading progress), the queue table sat empty by construction, and the page template had no route. - Ingest: the KOReader sync loop, on an unresolvable file, upserts unlinked_books (one row per device + content id; re-syncs bump last_seen_at and refresh device-reported metadata) instead of dropping the file. Schema gains the (device_id, content_id) unique index the upsert needs. - Page: GET /unlinked-books routes the existing page template, listing the caller's own unresolved rows (ListUnresolvedUnlinkedBooksForUser — devices.user_id scoping; no cross-user leakage). - Dashboard banner: a warning card above the shelves, rendered only when the caller's queue is non-empty (hidden-unless-needed), linking to the page. - Resolve flow unchanged and already gated: MatchingHandler.LinkBook verifies device ownership and target-item visibility (403s) and creates the device file alias, so the next sync matches by path. SyncHandler.LinkBook and Handler.LinkBook are unrouted dead handlers — triage candidates, not part of the live flow.
This commit is contained in:
1 parent
9e831085ef
commit
d74db03cf3
10 files changed
+823
-454
No files matched your search
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
-- ============================================
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user