Compare commits

...
3 Commits
Author SHA1 Message Date
John O'Keefe 2517baedea feat(koreader): device bridge for ratings, reviews and reading-time stats
Release / build-and-push (push) Successful in 3m4s
Extends the device-authed koreader surface with the handoff item 4
Batch B contract:

- GET/POST /api/sync/koreader/rating — the device mirrors the .sdr
  summary's 5-star rating against the 1-10 server scale (POST 0 clears).
- GET/POST /api/sync/koreader/review — raw-markdown review mirror
  (summary.note); empty body clears, matching the first-party PUT.
- POST /api/sync/koreader/stats — the device pushes its CUMULATIVE
  total reading seconds; the server watermark (highest total ever
  converted, read from the koreader sessions' own device_metadata)
  converts only the delta into a reading_history session attributed
  to the koreader device. Reinstalls cannot double-count; sub-minute
  deltas ride forward (no session, no watermark movement); a lagging
  device clamps to zero instead of dragging the total back.

Book identity resolves through the existing priority chain (uuid →
sha256 → alias → title/author). GET binding uses query tags; POST
endpoints bind the ref+payload once (an earlier resolve-then-bind
draft drained the body).

Live-verified against the dev server with the koreader device token:
rating set/get/clear, review mirror, and the stats sequence
900→session, 960→delta session, 970→rides forward, 500→clamps; the
book's stats card and the By Device panel both pick up the split.
2026-10-15 11:26:47 -04:00
John O'Keefe 2117ec6181 feat(web): book reviews — markdown, sibling of ratings, full API + page surface
media_reviews table mirrors media_ratings' shape (unique per user+item,
cascade deletes, updated_at for LWW) but standalone: a review without a
rating must work. Body is RAW MARKDOWN — storage and every payload carry
the source; rendering happens only at display sites (same doctrine as
notes, same sanitization pipeline: markdown-it + DOMPurify).

API: GET/PUT/DELETE /api/media-items/:id/review (PUT upserts; an empty
body deletes — clearing via an editor is a delete, not an empty row).
Book-detail page payload now carries the review beside the rating.

Page: the review renders above the synopsis as a scrolling card (same
max-height box as the summary), hydrated client-side through
renderNoteHtml; 'Write review'/'Edit review' sits next to the rating
stars and opens a raw-markdown editor dialog (save / delete / cancel).

sqlc regenerated; schema applied to the dev DB. Live-verified: upsert,
get, empty-delete, page markup, and the app round-trip against the dev
server.
2026-10-15 10:53:53 -04:00
John O'Keefe fd6f0e3353 fix(web): scan progress — correct percentages, no layout shift, refresh-survivable
Bug: the admin 'Scanning Libraries' widget fed the server's 0-1
progress straight into percent strings and bar widths, so the overall
and per-library percentages capped at '1%' and the bars never filled.
Both now scale by 100 (matching the setup page, which was already
correct).

Bug: scan state only reached clients via websocket events, so a page
opened (or refreshed) mid-scan showed nothing until the next tick —
and the admin widget never re-attached at all. The worker now exposes
active jobs (with their library_id so rows keep their names), served
as GET /api/scanner/active (admin, re-attaches the widget + polling)
and GET /api/scanner/active-summary (any user, restores the sidebar
indicator on load). Idle hides both, exactly as before.

Annoyance: the sidebar's 'Scanning… N%' was a block element between
the logo and the nav, shoving every menu entry down while a scan ran.
The percent now lives inside the fixed-height logo row next to the
spinner (spinner last, so growing digits extend leftward); nothing
below it moves.
2026-10-15 08:29:04 -04:00
26 changed files with 1262 additions and 348 deletions

No files matched your search

+18
View File
@@ -345,6 +345,20 @@ CREATE TABLE IF NOT EXISTS media_ratings (
UNIQUE(media_item_id, user_id) UNIQUE(media_item_id, user_id)
); );
-- Create media_reviews table: the user's markdown book review. Sibling of
-- media_ratings (same uniqueness + cascade shape) but standalone — a review
-- without a rating (and vice versa) must work. Stored as RAW markdown and
-- rendered only at display sites (same doctrine as notes).
CREATE TABLE IF NOT EXISTS media_reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
review TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(media_item_id, user_id)
);
-- Create media_notes table for user notes on media items -- Create media_notes table for user notes on media items
CREATE TABLE IF NOT EXISTS media_notes ( CREATE TABLE IF NOT EXISTS media_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -602,6 +616,10 @@ CREATE INDEX IF NOT EXISTS idx_reading_progress_user_id ON reading_progress(user
CREATE INDEX IF NOT EXISTS idx_media_ratings_media_item_id ON media_ratings(media_item_id); CREATE INDEX IF NOT EXISTS idx_media_ratings_media_item_id ON media_ratings(media_item_id);
CREATE INDEX IF NOT EXISTS idx_media_ratings_user_id ON media_ratings(user_id); CREATE INDEX IF NOT EXISTS idx_media_ratings_user_id ON media_ratings(user_id);
-- Reviews indexes
CREATE INDEX IF NOT EXISTS idx_media_reviews_media_item_id ON media_reviews(media_item_id);
CREATE INDEX IF NOT EXISTS idx_media_reviews_user_id ON media_reviews(user_id);
-- Notes and highlights indexes -- Notes and highlights indexes
CREATE INDEX IF NOT EXISTS idx_media_notes_media_item_id ON media_notes(media_item_id); CREATE INDEX IF NOT EXISTS idx_media_notes_media_item_id ON media_notes(media_item_id);
CREATE INDEX IF NOT EXISTS idx_media_notes_user_id ON media_notes(user_id); CREATE INDEX IF NOT EXISTS idx_media_notes_user_id ON media_notes(user_id);
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.31.0 // sqlc v1.31.1
package database package database
+10 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.31.0 // sqlc v1.31.1
package database package database
@@ -352,6 +352,15 @@ type MediaRatings struct {
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
} }
type MediaReviews struct {
ID pgtype.UUID `db:"id" json:"id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
Review string `db:"review" json:"review"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
type OpdsTokens struct { type OpdsTokens 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"`
+10 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.31.0 // sqlc v1.31.1
package database package database
@@ -142,6 +142,7 @@ type Querier interface {
DeleteMediaItemFormat(ctx context.Context, id pgtype.UUID) error DeleteMediaItemFormat(ctx context.Context, id pgtype.UUID) error
DeleteMediaNote(ctx context.Context, arg DeleteMediaNoteParams) error DeleteMediaNote(ctx context.Context, arg DeleteMediaNoteParams) error
DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error
DeleteMediaReview(ctx context.Context, arg DeleteMediaReviewParams) error
DeleteProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error) DeleteProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error)
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
DeleteSavedFilter(ctx context.Context, arg DeleteSavedFilterParams) (SavedFilters, error) DeleteSavedFilter(ctx context.Context, arg DeleteSavedFilterParams) (SavedFilters, error)
@@ -234,6 +235,12 @@ type Querier interface {
GetKoboShelfBooksByShelfName(ctx context.Context, arg GetKoboShelfBooksByShelfNameParams) ([]GetKoboShelfBooksByShelfNameRow, error) GetKoboShelfBooksByShelfName(ctx context.Context, arg GetKoboShelfBooksByShelfNameParams) ([]GetKoboShelfBooksByShelfNameRow, error)
// Get Kobo shelves by collection // Get Kobo shelves by collection
GetKoboShelvesByCollection(ctx context.Context, arg GetKoboShelvesByCollectionParams) ([]GetKoboShelvesByCollectionRow, error) GetKoboShelvesByCollection(ctx context.Context, arg GetKoboShelvesByCollectionParams) ([]GetKoboShelvesByCollectionRow, error)
// KOReader stats conversion: the device pushes its CUMULATIVE total reading
// seconds for a book; the watermark is the highest total already converted,
// read from the koreader sessions' own device_metadata. Delta-only insertion
// means a reinstall of the device cannot double-count, and sub-threshold
// deltas are never lost (they stay folded into the next push's delta).
GetKoreaderStatWatermark(ctx context.Context, arg GetKoreaderStatWatermarkParams) (interface{}, error)
GetLibrary(ctx context.Context, id pgtype.UUID) (GetLibraryRow, error) GetLibrary(ctx context.Context, id pgtype.UUID) (GetLibraryRow, error)
GetLibraryByFolder(ctx context.Context, folderPath string) (GetLibraryByFolderRow, error) GetLibraryByFolder(ctx context.Context, folderPath string) (GetLibraryByFolderRow, error)
GetLibraryByFolderPathPrefix(ctx context.Context, folderPath string) (GetLibraryByFolderPathPrefixRow, error) GetLibraryByFolderPathPrefix(ctx context.Context, folderPath string) (GetLibraryByFolderPathPrefixRow, error)
@@ -299,6 +306,7 @@ type Querier interface {
GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error) GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error)
GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error) GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error)
GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error) GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error)
GetMediaReview(ctx context.Context, arg GetMediaReviewParams) (MediaReviews, error)
GetNextRetryTime(ctx context.Context) (interface{}, error) GetNextRetryTime(ctx context.Context) (interface{}, error)
GetNotStartedItems(ctx context.Context, arg GetNotStartedItemsParams) ([]MediaItems, error) GetNotStartedItems(ctx context.Context, arg GetNotStartedItemsParams) ([]MediaItems, error)
// Get OPDS token // Get OPDS token
@@ -573,6 +581,7 @@ type Querier interface {
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error) UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error) UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error)
UpsertMediaReview(ctx context.Context, arg UpsertMediaReviewParams) (MediaReviews, error)
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error) UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error) UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error) UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
+93 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.31.0 // sqlc v1.31.1
// source: queries.sql // source: queries.sql
package database package database
@@ -2262,6 +2262,20 @@ func (q *Queries) DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingPa
return err return err
} }
const DeleteMediaReview = `-- name: DeleteMediaReview :exec
DELETE FROM media_reviews WHERE media_item_id = $1 AND user_id = $2
`
type DeleteMediaReviewParams struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
func (q *Queries) DeleteMediaReview(ctx context.Context, arg DeleteMediaReviewParams) error {
_, err := q.db.Exec(ctx, DeleteMediaReview, arg.MediaItemID, arg.UserID)
return err
}
const DeleteProcessingIssue = `-- name: DeleteProcessingIssue :one const DeleteProcessingIssue = `-- name: DeleteProcessingIssue :one
DELETE FROM processing_issues DELETE FROM processing_issues
WHERE id = $1 WHERE id = $1
@@ -4610,6 +4624,31 @@ func (q *Queries) GetKoboShelvesByCollection(ctx context.Context, arg GetKoboShe
return items, nil return items, nil
} }
const GetKoreaderStatWatermark = `-- name: GetKoreaderStatWatermark :one
SELECT COALESCE(MAX((device_metadata->>'total_seconds')::bigint), 0) AS total_seconds
FROM reading_history
WHERE user_id = $1
AND media_item_id = $2
AND device_metadata->>'source' = 'koreader'
`
type GetKoreaderStatWatermarkParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
}
// KOReader stats conversion: the device pushes its CUMULATIVE total reading
// seconds for a book; the watermark is the highest total already converted,
// read from the koreader sessions' own device_metadata. Delta-only insertion
// means a reinstall of the device cannot double-count, and sub-threshold
// deltas are never lost (they stay folded into the next push's delta).
func (q *Queries) GetKoreaderStatWatermark(ctx context.Context, arg GetKoreaderStatWatermarkParams) (interface{}, error) {
row := q.db.QueryRow(ctx, GetKoreaderStatWatermark, arg.UserID, arg.MediaItemID)
var total_seconds interface{}
err := row.Scan(&total_seconds)
return total_seconds, err
}
const GetLibrary = `-- name: GetLibrary :one const GetLibrary = `-- name: GetLibrary :one
SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, lt.name as type_name, lt.description as type_description SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, lt.name as type_name, lt.description as type_description
FROM libraries l FROM libraries l
@@ -6518,6 +6557,29 @@ func (q *Queries) GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID)
return items, nil return items, nil
} }
const GetMediaReview = `-- name: GetMediaReview :one
SELECT id, media_item_id, user_id, review, created_at, updated_at FROM media_reviews WHERE media_item_id = $1 AND user_id = $2
`
type GetMediaReviewParams struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
func (q *Queries) GetMediaReview(ctx context.Context, arg GetMediaReviewParams) (MediaReviews, error) {
row := q.db.QueryRow(ctx, GetMediaReview, arg.MediaItemID, arg.UserID)
var i MediaReviews
err := row.Scan(
&i.ID,
&i.MediaItemID,
&i.UserID,
&i.Review,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const GetNextRetryTime = `-- name: GetNextRetryTime :one const GetNextRetryTime = `-- name: GetNextRetryTime :one
SELECT SELECT
CASE CASE
@@ -14976,6 +15038,36 @@ func (q *Queries) UpsertGlobalDashboardPreferences(ctx context.Context, arg Upse
return i, err return i, err
} }
const UpsertMediaReview = `-- name: UpsertMediaReview :one
INSERT INTO media_reviews (media_item_id, user_id, review)
VALUES ($1, $2, $3)
ON CONFLICT (media_item_id, user_id)
DO UPDATE SET
review = EXCLUDED.review,
updated_at = NOW()
RETURNING id, media_item_id, user_id, review, created_at, updated_at
`
type UpsertMediaReviewParams struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
Review string `db:"review" json:"review"`
}
func (q *Queries) UpsertMediaReview(ctx context.Context, arg UpsertMediaReviewParams) (MediaReviews, error) {
row := q.db.QueryRow(ctx, UpsertMediaReview, arg.MediaItemID, arg.UserID, arg.Review)
var i MediaReviews
err := row.Scan(
&i.ID,
&i.MediaItemID,
&i.UserID,
&i.Review,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const UpsertPanelData = `-- name: UpsertPanelData :one const UpsertPanelData = `-- name: UpsertPanelData :one
INSERT INTO panel_data (media_item_id, page_number, detection_method, panels) INSERT INTO panel_data (media_item_id, page_number, detection_method, panels)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
+27
View File
@@ -550,6 +550,33 @@ RETURNING *;
-- name: DeleteMediaRating :exec -- name: DeleteMediaRating :exec
DELETE FROM media_ratings WHERE media_item_id = $1 AND user_id = $2; DELETE FROM media_ratings WHERE media_item_id = $1 AND user_id = $2;
-- name: UpsertMediaReview :one
INSERT INTO media_reviews (media_item_id, user_id, review)
VALUES ($1, $2, $3)
ON CONFLICT (media_item_id, user_id)
DO UPDATE SET
review = EXCLUDED.review,
updated_at = NOW()
RETURNING *;
-- name: GetMediaReview :one
SELECT * FROM media_reviews WHERE media_item_id = $1 AND user_id = $2;
-- name: DeleteMediaReview :exec
DELETE FROM media_reviews WHERE media_item_id = $1 AND user_id = $2;
-- KOReader stats conversion: the device pushes its CUMULATIVE total reading
-- seconds for a book; the watermark is the highest total already converted,
-- read from the koreader sessions' own device_metadata. Delta-only insertion
-- means a reinstall of the device cannot double-count, and sub-threshold
-- deltas are never lost (they stay folded into the next push's delta).
-- name: GetKoreaderStatWatermark :one
SELECT COALESCE(MAX((device_metadata->>'total_seconds')::bigint), 0) AS total_seconds
FROM reading_history
WHERE user_id = $1
AND media_item_id = $2
AND device_metadata->>'source' = 'koreader';
-- Search Media Items queries -- Search Media Items queries
-- name: SearchMediaItems :many -- name: SearchMediaItems :many
SELECT mi.*, l.name as library_name, lt.name as library_type_name SELECT mi.*, l.name as library_name, lt.name as library_type_name
+260
View File
@@ -6,6 +6,7 @@ import (
wsync "bookhoard/internal/sync" wsync "bookhoard/internal/sync"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
@@ -14,6 +15,8 @@ import (
"time" "time"
"unicode/utf8" "unicode/utf8"
"github.com/jackc/pgx/v5"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
@@ -1729,3 +1732,260 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
"timestamp": time.Now().Format(time.RFC3339), "timestamp": time.Now().Format(time.RFC3339),
}) })
} }
// ---------------------------------------------------------------------------
// Ratings / review / reading-time bridge (handoff item 4 Batch B)
//
// The plugin mirrors the book's .sdr summary (summary.rating 5-star,
// summary.note = the Book Status "Review" box) against the server's
// 1-10 rating and raw-markdown review, and pushes its cumulative
// reading time from statistics.sqlite3. Stats are PUSH-ONLY and
// watermark-converted: the device sends its TOTAL seconds for the
// book and the server inserts only the delta as a reading_history
// session attributed to this device, so totals unify in the one
// stats pipeline while the By Device panel keeps the per-client
// split. A device reinstall cannot double-count (the watermark is
// server-side), and sub-threshold deltas simply ride forward.
// ---------------------------------------------------------------------------
// koreaderBookRef is the minimal book identity the bridge endpoints
// accept; it resolves through the same priority chain as progress.
type koreaderBookRef struct {
UUID string `json:"uuid,omitempty" query:"uuid"`
SHA256 string `json:"sha256,omitempty" query:"sha256"`
Title string `json:"title,omitempty" query:"title"`
Authors []string `json:"authors,omitempty"`
}
func (r koreaderBookRef) toProgress() KOReaderBookProgress {
return KOReaderBookProgress{
UUID: r.UUID,
SHA256: r.SHA256,
Title: r.Title,
Authors: r.Authors,
}
}
// resolveParsedRef resolves an already-bound book identity to a media
// item. Endpoints bind ONCE — a resolve helper that re-bound the body
// would drain it and break the payload bind that follows.
func (h *KOReaderHandler) resolveParsedRef(c *echo.Context, device database.Devices, ref koreaderBookRef) (pgtype.UUID, error) {
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, device.UserID, ref.toProgress())
if !mediaItemID.Valid {
return pgtype.UUID{}, fmt.Errorf("book not found on server")
}
return mediaItemID, nil
}
// GetKOReaderRating handles GET /api/sync/koreader/rating?uuid=&sha256=
func (h *KOReaderHandler) GetKOReaderRating(c *echo.Context) error {
device := c.Get("device").(database.Devices)
var ref koreaderBookRef
if err := c.Bind(&ref); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
mediaItemID, err := h.resolveParsedRef(c, device, ref)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
rating, err := h.db.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{
MediaItemID: mediaItemID,
UserID: device.UserID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusOK, map[string]interface{}{"rating": nil})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"rating": rating.Rating})
}
// SetKOReaderRating handles POST /api/sync/koreader/rating. Rating 0
// (koreader's "unrated") clears the server rating.
func (h *KOReaderHandler) SetKOReaderRating(c *echo.Context) error {
device := c.Get("device").(database.Devices)
var req struct {
koreaderBookRef
Rating int `json:"rating" validate:"min=0,max=10"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
mediaItemID, err := h.resolveParsedRef(c, device, req.koreaderBookRef)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
if req.Rating <= 0 {
if err := h.db.DeleteMediaRating(c.Request().Context(), database.DeleteMediaRatingParams{
MediaItemID: mediaItemID,
UserID: device.UserID,
}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"rating": nil})
}
rating, err := h.db.CreateMediaRating(c.Request().Context(), database.CreateMediaRatingParams{
MediaItemID: mediaItemID,
UserID: device.UserID,
Rating: int32(req.Rating),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"rating": rating.Rating})
}
// GetKOReaderReview handles GET /api/sync/koreader/review. Returns the
// RAW MARKDOWN source (koreader's review box shows it literally, the
// same accepted tradeoff as notes).
func (h *KOReaderHandler) GetKOReaderReview(c *echo.Context) error {
device := c.Get("device").(database.Devices)
var ref koreaderBookRef
if err := c.Bind(&ref); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
mediaItemID, err := h.resolveParsedRef(c, device, ref)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
review, err := h.db.GetMediaReview(c.Request().Context(), database.GetMediaReviewParams{
MediaItemID: mediaItemID,
UserID: device.UserID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusOK, map[string]interface{}{"review": nil})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"review": review.Review})
}
// SetKOReaderReview handles POST /api/sync/koreader/review. An empty
// body clears the review (mirrors the first-party PUT contract).
func (h *KOReaderHandler) SetKOReaderReview(c *echo.Context) error {
device := c.Get("device").(database.Devices)
var req struct {
koreaderBookRef
Review string `json:"review" validate:"max=50000"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
mediaItemID, err := h.resolveParsedRef(c, device, req.koreaderBookRef)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
if strings.TrimSpace(req.Review) == "" {
if err := h.db.DeleteMediaReview(c.Request().Context(), database.DeleteMediaReviewParams{
MediaItemID: mediaItemID,
UserID: device.UserID,
}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"review": nil})
}
review, err := h.db.UpsertMediaReview(c.Request().Context(), database.UpsertMediaReviewParams{
MediaItemID: mediaItemID,
UserID: device.UserID,
Review: req.Review,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"review": review.Review})
}
// PushKOReaderStats handles POST /api/sync/koreader/stats. The device
// sends its cumulative total reading seconds for the book; only the
// delta above the stored watermark becomes a reading_history session
// (attributed to the koreader device, metadata carrying the new
// cumulative marker). Sub-minute deltas ride forward: no session, no
// watermark movement, nothing lost.
func (h *KOReaderHandler) PushKOReaderStats(c *echo.Context) error {
device := c.Get("device").(database.Devices)
var req struct {
koreaderBookRef
TotalSeconds int64 `json:"total_seconds" validate:"min=0,max=2000000000"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
mediaItemID, err := h.resolveParsedRef(c, device, req.koreaderBookRef)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
// sqlc scans COALESCE(MAX(...)) as interface{}; pgx hands bigint
// back as int64.
watermarkAny, err := h.db.GetKoreaderStatWatermark(c.Request().Context(), database.GetKoreaderStatWatermarkParams{
UserID: device.UserID,
MediaItemID: mediaItemID,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
watermark, _ := watermarkAny.(int64)
// Delta against the stored watermark (the highest total ever
// converted for this user+book across ALL koreader devices). When
// another device is ahead, the delta clamps to zero so a lagging
// device can never drag the total backwards.
delta := req.TotalSeconds - watermark
if delta < 0 {
delta = 0
}
result := map[string]interface{}{
"total_seconds": req.TotalSeconds,
"watermark": watermark,
"delta_seconds": delta,
"session_recorded": false,
}
if delta >= 60 {
now := time.Now()
start := now.Add(-time.Duration(delta) * time.Second)
metadata, _ := json.Marshal(map[string]interface{}{
"source": "koreader",
"total_seconds": req.TotalSeconds,
})
if _, err := h.db.CreateReadingHistory(c.Request().Context(), database.CreateReadingHistoryParams{
UserID: device.UserID,
MediaItemID: mediaItemID,
DeviceID: device.ID,
ProgressPercentage: pgtype.Float8{Float64: 0, Valid: false},
ReadingSessionStart: pgtype.Timestamptz{Time: start, Valid: true},
ReadingSessionEnd: pgtype.Timestamptz{Time: now, Valid: true},
PagesRead: pgtype.Int4{Int32: 0, Valid: false},
TimeSpentSeconds: pgtype.Int4{Int32: int32(delta), Valid: true},
DeviceMetadata: metadata,
}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
result["session_recorded"] = true
result["session_seconds"] = delta
}
return c.JSON(http.StatusOK, result)
}
+105
View File
@@ -1012,6 +1012,111 @@ func (mh *MediaHandler) DeleteMediaRating(c *echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"message": "rating deleted"}) return c.JSON(http.StatusOK, map[string]string{"message": "rating deleted"})
} }
// UpsertMediaReview handles POST and PUT /api/media-items/:id/review.
// The review body is RAW MARKDOWN (same doctrine as notes): storage and
// every sync payload carry the source string; rendering happens only at
// display sites. An empty body deletes the review — clearing via the
// editor is a delete, not an empty row.
func (mh *MediaHandler) UpsertMediaReview(c *echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
var req struct {
Review string `json:"review" validate:"max=50000"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
trimmed := strings.TrimSpace(req.Review)
if trimmed == "" {
err := mh.db.DeleteMediaReview(c.Request().Context(), database.DeleteMediaReviewParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"review": nil})
}
review, err := mh.db.UpsertMediaReview(c.Request().Context(), database.UpsertMediaReviewParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Review: req.Review,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, review)
}
// GetMediaReview handles GET /api/media-items/:id/review
func (mh *MediaHandler) GetMediaReview(c *echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
review, err := mh.db.GetMediaReview(c.Request().Context(), database.GetMediaReviewParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusOK, map[string]interface{}{"review": nil})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, review)
}
// DeleteMediaReview handles DELETE /api/media-items/:id/review
func (mh *MediaHandler) DeleteMediaReview(c *echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
err = mh.db.DeleteMediaReview(c.Request().Context(), database.DeleteMediaReviewParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"message": "review deleted"})
}
// GetMediaReadingProgress handles GET /api/media-items/:id/progress // GetMediaReadingProgress handles GET /api/media-items/:id/progress
func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error { func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error {
userID := c.Get("user_id").(string) userID := c.Get("user_id").(string)
+1
View File
@@ -13,6 +13,7 @@ type MediaDetail struct {
// User-specific data // User-specific data
Rating *database.MediaRatings `json:"rating,omitempty"` Rating *database.MediaRatings `json:"rating,omitempty"`
Review *database.MediaReviews `json:"review,omitempty"` // raw markdown
Collections []database.Collections `json:"collections"` Collections []database.Collections `json:"collections"`
ReadingProgress *database.ReadingProgress `json:"reading_progress,omitempty"` ReadingProgress *database.ReadingProgress `json:"reading_progress,omitempty"`
+32
View File
@@ -177,6 +177,38 @@ func (h *Handler) GetScanStatus(c *echo.Context) error {
}) })
} }
// GetActiveScans handles GET /api/scanner/active (admin): every
// pending/running job. Lets the admin page re-attach to in-flight
// scans after a page refresh instead of only tracking scans it
// started itself in this page load.
func (h *Handler) GetActiveScans(c *echo.Context) error {
jobs := h.worker.GetActiveScanJobs()
return c.JSON(http.StatusOK, map[string]interface{}{
"scanning": len(jobs) > 0,
"jobs": jobs,
})
}
// GetScanSummary handles GET /api/scanner/active-summary (any
// authenticated user): whether a scan is running and its average
// progress (0-1). Powers the sitewide sidebar indicator, which must
// survive page refreshes — websocket events alone only reach pages
// that were already open when the scan started.
func (h *Handler) GetScanSummary(c *echo.Context) error {
jobs := h.worker.GetActiveScanJobs()
progress := 0.0
if len(jobs) > 0 {
for _, job := range jobs {
progress += job.Progress
}
progress /= float64(len(jobs))
}
return c.JSON(http.StatusOK, map[string]interface{}{
"scanning": len(jobs) > 0,
"progress": progress,
})
}
// StartWatchModeForLibrary starts watching a specific library's folders // StartWatchModeForLibrary starts watching a specific library's folders
func (h *Handler) StartWatchModeForLibrary(ctx context.Context, libraryID pgtype.UUID, adminID pgtype.UUID) error { func (h *Handler) StartWatchModeForLibrary(ctx context.Context, libraryID pgtype.UUID, adminID pgtype.UUID) error {
h.mu.Lock() h.mu.Lock()
+12
View File
@@ -1603,6 +1603,17 @@ func registerFrontendRoutes(cfg *Config) {
rating = &userRating rating = &userRating
} }
// Fetch the user's review (raw markdown; rendered client-side
// through the notes pipeline).
var review *database.MediaReviews
userReview, err := cfg.Queries.GetMediaReview(c.Request().Context(), database.GetMediaReviewParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
})
if err == nil {
review = &userReview
}
// Fetch collections // Fetch collections
collections, _ := cfg.Queries.GetCollectionsForBook(c.Request().Context(), pgMediaUUID) collections, _ := cfg.Queries.GetCollectionsForBook(c.Request().Context(), pgMediaUUID)
@@ -1676,6 +1687,7 @@ func registerFrontendRoutes(cfg *Config) {
MediaItems: mediaItem, // Embedded - ALL fields available MediaItems: mediaItem, // Embedded - ALL fields available
FileLocation: fileLocation, FileLocation: fileLocation,
Rating: rating, Rating: rating,
Review: review,
Collections: collections, Collections: collections,
ReadingProgress: progress, ReadingProgress: progress,
BookStats: bookStats, BookStats: bookStats,
+7
View File
@@ -25,6 +25,13 @@ func registerMediaRoutes(cfg *Config) {
protected.PUT("/media-items/:id/rating", cfg.MediaHandler.UpdateMediaRating) protected.PUT("/media-items/:id/rating", cfg.MediaHandler.UpdateMediaRating)
protected.DELETE("/media-items/:id/rating", cfg.MediaHandler.DeleteMediaRating) protected.DELETE("/media-items/:id/rating", cfg.MediaHandler.DeleteMediaRating)
// Media review routes (all authenticated users). Raw markdown in,
// raw markdown out — rendered only at display sites.
protected.POST("/media-items/:id/review", cfg.MediaHandler.UpsertMediaReview)
protected.GET("/media-items/:id/review", cfg.MediaHandler.GetMediaReview)
protected.PUT("/media-items/:id/review", cfg.MediaHandler.UpsertMediaReview)
protected.DELETE("/media-items/:id/review", cfg.MediaHandler.DeleteMediaReview)
// Progress routes (all authenticated users) // Progress routes (all authenticated users)
protected.GET("/media-items/:id/progress", cfg.MediaHandler.GetMediaReadingProgress) protected.GET("/media-items/:id/progress", cfg.MediaHandler.GetMediaReadingProgress)
protected.PUT("/media-items/:id/progress", cfg.MediaHandler.UpdateMediaReadingProgress) protected.PUT("/media-items/:id/progress", cfg.MediaHandler.UpdateMediaReadingProgress)
+2 -2
View File
@@ -332,9 +332,9 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
// Register progress routes with actual handler // Register progress routes with actual handler
registerProgressRoutes(cfg, scannerHandler) registerProgressRoutes(cfg, scannerHandler)
// Register scanner routes (admin only) // Register scanner routes (admin only, plus the sitewide summary)
admin := protected.Group("", handlers.AdminMiddleware) admin := protected.Group("", handlers.AdminMiddleware)
registerScannerRoutes(admin, scannerHandler) registerScannerRoutes(protected, admin, scannerHandler)
// Hash conflict routes (admin only) // Hash conflict routes (admin only)
admin.GET("/api/admin/hash-conflicts", cfg.HashConflictsHandler.ListHashConflicts) admin.GET("/api/admin/hash-conflicts", cfg.HashConflictsHandler.ListHashConflicts)
+7 -1
View File
@@ -7,12 +7,18 @@ import (
) )
// registerScannerRoutes registers all scanner-related endpoints // registerScannerRoutes registers all scanner-related endpoints
func registerScannerRoutes(admin *echo.Group, h *handlers.Handler) { func registerScannerRoutes(protected, admin *echo.Group, h *handlers.Handler) {
// Any authenticated user: the sitewide sidebar indicator needs to
// know a scan is running after a page refresh (websocket events
// only reach pages that were already open).
protected.GET("/scanner/active-summary", h.GetScanSummary)
// Scanner routes (admin only) // Scanner routes (admin only)
admin.POST("/scanner/scan", h.ScanLibrary) admin.POST("/scanner/scan", h.ScanLibrary)
admin.POST("/scanner/start", h.StartScanner) admin.POST("/scanner/start", h.StartScanner)
admin.POST("/scanner/stop", h.StopScanner) admin.POST("/scanner/stop", h.StopScanner)
admin.GET("/scanner/status/:jobId", h.GetScanStatus) admin.GET("/scanner/status/:jobId", h.GetScanStatus)
admin.GET("/scanner/active", h.GetActiveScans)
// Watch mode routes (admin only) // Watch mode routes (admin only)
admin.POST("/scanner/watch/start", h.StartWatchMode) admin.POST("/scanner/watch/start", h.StartWatchMode)
+5
View File
@@ -28,6 +28,11 @@ func registerSyncRoutes(cfg *Config) {
koreaderSync.GET("/metadata/:uuid", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetMetadata)) koreaderSync.GET("/metadata/:uuid", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetMetadata))
koreaderSync.GET("/library", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetLibrary)) koreaderSync.GET("/library", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetLibrary))
koreaderSync.POST("/bookmarks", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.SyncBookmarks)) koreaderSync.POST("/bookmarks", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.SyncBookmarks))
koreaderSync.GET("/rating", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetKOReaderRating))
koreaderSync.POST("/rating", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.SetKOReaderRating))
koreaderSync.GET("/review", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetKOReaderReview))
koreaderSync.POST("/review", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.SetKOReaderReview))
koreaderSync.POST("/stats", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.PushKOReaderStats))
// Kobo sync routes (device authentication required) // Kobo sync routes (device authentication required)
// Kobo devices use URL path: /api/sync/kobo/{token}/markup // Kobo devices use URL path: /api/sync/kobo/{token}/markup
+24
View File
@@ -75,6 +75,10 @@ func (j *Job) UpdateProgress(progress float64, filesScanned, newItems, errors in
type JobResult struct { type JobResult struct {
JobID string `json:"job_id"` JobID string `json:"job_id"`
Status JobStatus `json:"status"` Status JobStatus `json:"status"`
// LibraryID carries the scan job's library (from Params) so clients
// can label progress rows after a page refresh, when the original
// start-request context is gone.
LibraryID string `json:"library_id,omitempty"`
Error string `json:"error"` Error string `json:"error"`
Result interface{} `json:"result"` Result interface{} `json:"result"`
Progress float64 `json:"progress"` Progress float64 `json:"progress"`
@@ -237,10 +241,12 @@ func (w *Worker) worker() {
} }
func (w *Worker) processJob(job *Job) { func (w *Worker) processJob(job *Job) {
libID, _ := job.Params["library_id"].(string)
w.mu.Lock() w.mu.Lock()
w.results[job.ID] = &JobResult{ w.results[job.ID] = &JobResult{
JobID: job.ID, JobID: job.ID,
Status: JobStatusRunning, Status: JobStatusRunning,
LibraryID: libID,
} }
w.mu.Unlock() w.mu.Unlock()
@@ -315,6 +321,7 @@ func (w *Worker) processJob(job *Job) {
w.results[job.ID] = &JobResult{ w.results[job.ID] = &JobResult{
JobID: job.ID, JobID: job.ID,
Status: status, Status: status,
LibraryID: libID,
Error: func() string { Error: func() string {
if err != nil { if err != nil {
return err.Error() return err.Error()
@@ -981,6 +988,23 @@ func (w *Worker) GetJobStatus(jobID string) (*JobResult, bool) {
return result, exists return result, exists
} }
// GetActiveScanJobs returns snapshots of every pending/running job.
// Clients use it to re-attach to in-flight scans (e.g. after a page
// refresh mid-scan) instead of only learning about scans from
// websocket events.
func (w *Worker) GetActiveScanJobs() []JobResult {
w.mu.RLock()
defer w.mu.RUnlock()
active := make([]JobResult, 0)
for _, result := range w.results {
if result.Status == JobStatusRunning || result.Status == JobStatusPending {
active = append(active, *result)
}
}
return active
}
func (w *Worker) CancelJob(jobID string) error { func (w *Worker) CancelJob(jobID string) error {
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
+60
View File
@@ -0,0 +1,60 @@
package services
import (
"testing"
)
// GetActiveScanJobs backs the refresh-survival contract: a client that
// loads the page mid-scan must be able to discover pending/running jobs
// (with their library) rather than only learning about scans from
// websocket events emitted after the page opened.
func TestGetActiveScanJobs(t *testing.T) {
w := NewWorker(1, nil)
seed := map[string]*JobResult{
"running-scan": {JobID: "running-scan", Status: JobStatusRunning, LibraryID: "lib-1", Progress: 0.42},
"pending-scan": {JobID: "pending-scan", Status: JobStatusPending, LibraryID: "lib-2"},
"done-scan": {JobID: "done-scan", Status: JobStatusCompleted, LibraryID: "lib-3"},
"failed-scan": {JobID: "failed-scan", Status: JobStatusFailed},
"cancelled": {JobID: "cancelled", Status: JobStatusCancelled},
}
w.mu.Lock()
w.results = seed
w.mu.Unlock()
active := w.GetActiveScanJobs()
if len(active) != 2 {
t.Fatalf("expected 2 active jobs, got %d (%+v)", len(active), active)
}
seen := map[string]JobResult{}
for _, job := range active {
seen[job.JobID] = job
}
running, ok := seen["running-scan"]
if !ok {
t.Fatal("running scan missing from active jobs")
}
if running.LibraryID != "lib-1" {
t.Errorf("expected library_id lib-1, got %q", running.LibraryID)
}
if running.Progress != 0.42 {
t.Errorf("expected progress 0.42, got %v", running.Progress)
}
if _, ok := seen["pending-scan"]; !ok {
t.Error("pending scan missing from active jobs")
}
for _, id := range []string{"done-scan", "failed-scan", "cancelled"} {
if _, ok := seen[id]; ok {
t.Errorf("terminal job %q must not appear as active", id)
}
}
}
func TestGetActiveScanJobsEmpty(t *testing.T) {
w := NewWorker(1, nil)
if jobs := w.GetActiveScanJobs(); len(jobs) != 0 {
t.Fatalf("expected no active jobs, got %d", len(jobs))
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ templ Admin(user User, stats AdminStats) {
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body x-data="admin" x-init="loadWatchStatus()" class="theme-{ user.Theme }"> <body x-data="admin" x-init="loadWatchStatus(); checkActiveScans()" class="theme-{ user.Theme }">
@Header(user, "/admin") @Header(user, "/admin")
<main class="p-8"> <main class="p-8">
<div class="max-w-4xl"> <div class="max-w-4xl">
+1 -1
View File
@@ -29,7 +29,7 @@ func Admin(user User, stats AdminStats) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Admin Dashboard - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body x-data=\"admin\" x-init=\"loadWatchStatus()\" class=\"theme-{ user.Theme }\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Admin Dashboard - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body x-data=\"admin\" x-init=\"loadWatchStatus(); checkActiveScans()\" class=\"theme-{ user.Theme }\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+52
View File
@@ -154,6 +154,14 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
class="ml-2 text-xs underline hover:opacity-70" class="ml-2 text-xs underline hover:opacity-70"
style="color: var(--text-secondary);" style="color: var(--text-secondary);"
>Clear</button> >Clear</button>
<!-- Review editor: markdown, same doctrine as notes -->
<button
type="button"
@click="openReviewEditor()"
class="ml-2 text-xs underline hover:opacity-70"
style="color: var(--text-secondary);"
x-text="hasReview ? 'Edit review' : 'Write review'"
></button>
<svg x-show="ratingSaving" class="animate-spin inline-block h-4 w-4 ml-1 align-middle" viewBox="0 0 24 24" fill="none" style="color: var(--text-secondary);"> <svg x-show="ratingSaving" class="animate-spin inline-block h-4 w-4 ml-1 align-middle" viewBox="0 0 24 24" fill="none" style="color: var(--text-secondary);">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
@@ -214,6 +222,20 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
</div> </div>
</div> </div>
<div class="xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 pb-10"> <div class="xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 pb-10">
<!-- My Review (raw markdown hydrated client-side through the
notes pipeline — same sanitization as every note display) -->
if book.Review != nil && book.Review.Review != "" {
<div id="review-raw" class="hidden">{ book.Review.Review }</div>
<div id="review-card" class="card p-6 mb-6">
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">My Review</h3>
<div
id="review-body"
class="note-rich max-h-[20rem] overflow-y-auto pr-2 text-sm leading-relaxed"
style="color: var(--text-secondary)"
x-html="reviewHtml"
></div>
</div>
}
<!-- Synopsis --> <!-- Synopsis -->
if book.Description.Valid && book.Description.String != "" { if book.Description.Valid && book.Description.String != "" {
<div class="card p-6 mb-6"> <div class="card p-6 mb-6">
@@ -477,6 +499,36 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
if user.Role == "admin" { if user.Role == "admin" {
@MetadataEditorModal(book) @MetadataEditorModal(book)
} }
<!-- Review editor: plain textarea over the RAW markdown (what
storage and sync carry); rendering happens only on display. -->
<div
x-show="reviewOpen"
x-cloak
class="fixed inset-0 flex items-center justify-center z-50 p-4"
style="background-color: var(--surface-overlay);"
@keydown.escape.window="reviewOpen = false"
>
<div class="absolute inset-0" @click="reviewOpen = false"></div>
<div class="relative card p-6 w-full max-w-2xl">
<h3 class="text-lg font-semibold mb-3" style="color: var(--text-primary)">Edit Review</h3>
<textarea
x-model="reviewDraft"
rows="12"
class="w-full rounded-xl border p-3 text-sm font-mono resize-y"
style="background-color: var(--bg-primary); border-color: var(--border); color: var(--text-primary);"
placeholder="Markdown supported — **bold**, lists, footnotes…"
></textarea>
<p class="mt-1 text-xs" style="color: var(--text-secondary)">Markdown — renders like notes. Saving an empty review deletes it.</p>
<div class="mt-4 flex items-center gap-2">
<button type="button" @click="saveReview()" :disabled="reviewSaving" class="btn btn-primary">
<span x-show="!reviewSaving">Save review</span>
<span x-show="reviewSaving">Saving…</span>
</button>
<button type="button" @click="deleteReview()" :disabled="reviewSaving || !hasReview" class="btn btn-secondary" style="color: var(--status-danger, #e5484d);">Delete</button>
<button type="button" @click="reviewOpen = false" class="btn btn-secondary ml-auto">Cancel</button>
</div>
</div>
</div>
@ErrorToast(errorMessage) @ErrorToast(errorMessage)
</body> </body>
</html> </html>
File diff suppressed because it is too large. Load diff
+16 -10
View File
@@ -24,11 +24,24 @@ templ Header(user User, currentPath string) {
@Icon("book-open", "h-6 w-6") @Icon("book-open", "h-6 w-6")
</span> </span>
<span class="font-bold text-lg tracking-tight" style="color: var(--text-primary)">Bookhoard</span> <span class="font-bold text-lg tracking-tight" style="color: var(--text-primary)">Bookhoard</span>
<svg <!-- Scanning indicator: lives INSIDE the fixed-height logo row so
it can never push the nav entries down. Spinner sits last so
the growing percent extends leftward, not into the spinner. -->
<div
x-show="scanning" x-show="scanning"
x-cloak x-cloak
x-transition x-transition
class="h-4 w-4 animate-spin ml-auto" class="ml-auto flex items-center gap-1.5 shrink-0"
aria-live="polite"
>
<span
x-show="scanProgress > 0"
class="text-xs font-medium"
style="color: var(--text-secondary);"
x-text="scanProgress + '%'"
></span>
<svg
class="h-4 w-4 animate-spin"
style="color: var(--accent);" style="color: var(--accent);"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -36,15 +49,8 @@ templ Header(user User, currentPath string) {
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg> </svg>
</div>
</a> </a>
<span
x-show="scanning && scanProgress > 0"
x-cloak
x-transition
class="block px-5 py-1.5 text-xs font-medium -mt-1"
style="color: var(--text-secondary);"
x-text="'Scanning… ' + scanProgress + '%'"
></span>
<!-- Primary navigation --> <!-- Primary navigation -->
<nav class="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto"> <nav class="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
<a href="/dashboard" class={ activeClass(currentPath, "/dashboard") }> <a href="/dashboard" class={ activeClass(currentPath, "/dashboard") }>
+11 -11
View File
@@ -37,7 +37,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, 2, "</span> <span class=\"font-bold text-lg tracking-tight\" style=\"color: var(--text-primary)\">Bookhoard</span> <svg x-show=\"scanning\" x-cloak x-transition class=\"h-4 w-4 animate-spin ml-auto\" style=\"color: var(--accent);\" fill=\"none\" viewBox=\"0 0 24 24\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path></svg></a> <span x-show=\"scanning && scanProgress > 0\" x-cloak x-transition class=\"block px-5 py-1.5 text-xs font-medium -mt-1\" style=\"color: var(--text-secondary);\" x-text=\"'Scanning… ' + scanProgress + '%'\"></span><!-- Primary navigation --><nav class=\"flex-1 px-3 py-4 space-y-0.5 overflow-y-auto\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</span> <span class=\"font-bold text-lg tracking-tight\" style=\"color: var(--text-primary)\">Bookhoard</span><!-- Scanning indicator: lives INSIDE the fixed-height logo row so\n\t\t\t it can never push the nav entries down. Spinner sits last so\n\t\t\t the growing percent extends leftward, not into the spinner. --><div x-show=\"scanning\" x-cloak x-transition class=\"ml-auto flex items-center gap-1.5 shrink-0\" aria-live=\"polite\"><span x-show=\"scanProgress > 0\" class=\"text-xs font-medium\" style=\"color: var(--text-secondary);\" x-text=\"scanProgress + '%'\"></span> <svg class=\"h-4 w-4 animate-spin\" style=\"color: var(--accent);\" fill=\"none\" viewBox=\"0 0 24 24\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path></svg></div></a><!-- Primary navigation --><nav class=\"flex-1 px-3 py-4 space-y-0.5 overflow-y-auto\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -290,7 +290,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeTheme('" + opt.Name + "'); openPanel = null") templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeTheme('" + opt.Name + "'); openPanel = null")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 107, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 113, Col: 68}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -303,7 +303,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 109, Col: 29} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 115, Col: 29}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -316,7 +316,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var18 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: " + opt.Color) templ_7745c5c3_Var18, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: " + opt.Color)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 112, Col: 130} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 118, Col: 130}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -329,7 +329,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 113, Col: 50} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 119, Col: 50}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -382,7 +382,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var22 string var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeWoodPaneling('" + w.Name + "')") templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeWoodPaneling('" + w.Name + "')")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 124, Col: 55} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 130, Col: 55}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -395,7 +395,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var23 string var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(w.Name) templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(w.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 126, Col: 26} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 132, Col: 26}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -418,7 +418,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var24 string var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-image: url(/static/textures/thumb-" + w.Name + ".webp); background-size: cover;") templ_7745c5c3_Var24, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-image: url(/static/textures/thumb-" + w.Name + ".webp); background-size: cover;")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 132, Col: 191} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 138, Col: 191}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -436,7 +436,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var25 string var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(w.Label) templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(w.Label)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 134, Col: 48} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 140, Col: 48}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -676,7 +676,7 @@ func SidebarUserMenu(user User) templ.Component {
var templ_7745c5c3_Var37 string var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username) templ_7745c5c3_Var37, 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: `header.templ`, Line: 224, Col: 58} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 230, Col: 58}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -760,7 +760,7 @@ func SidebarSignIn(currentPath string) templ.Component {
var templ_7745c5c3_Var39 string var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath) templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 272, Col: 60} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 278, Col: 60}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+59 -3
View File
@@ -138,7 +138,8 @@ function pollScanProgress(
} }
} }
const overallProgress = Math.round(totalProgress / jobIds.length); // status.progress arrives 0-1 from the server; render as percent.
const overallProgress = Math.round((totalProgress / jobIds.length) * 100);
const progressBar = document.getElementById( const progressBar = document.getElementById(
"scan-progress-bar", "scan-progress-bar",
) as HTMLElement; ) as HTMLElement;
@@ -172,14 +173,18 @@ function updateLibraryProgress(jobId: string, status: any): void {
const bar = document.getElementById(`bar-${jobId}`) as HTMLElement; const bar = document.getElementById(`bar-${jobId}`) as HTMLElement;
const statusText = document.getElementById(`status-${jobId}`) as HTMLElement; const statusText = document.getElementById(`status-${jobId}`) as HTMLElement;
// status.progress arrives 0-1 from the server; render as percent,
// otherwise the bar sits at sub-1% forever and never fills.
const percent = Math.round((status.progress || 0) * 100);
if (bar) { if (bar) {
bar.style.width = (status.progress || 0) + "%"; bar.style.width = percent + "%";
} }
if (statusText) { if (statusText) {
const statusMessages: Record<string, string> = { const statusMessages: Record<string, string> = {
pending: "Pending...", pending: "Pending...",
running: `Scanning... ${status.progress || 0}%`, running: `Scanning... ${percent}%`,
completed: `✓ Complete (${status.new_items || 0} items)`, completed: `✓ Complete (${status.new_items || 0} items)`,
failed: `✗ Failed`, failed: `✗ Failed`,
}; };
@@ -187,6 +192,55 @@ function updateLibraryProgress(jobId: string, status: any): void {
} }
} }
// On page load: if a scan is already running (started before this page
// was opened, or the page was refreshed mid-scan), surface the progress
// widget and re-attach polling. Websocket start-events only reach pages
// that were already open when the scan began.
async function checkActiveScans(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const resp = await fetch("/api/scanner/active", {
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) return;
const data = await resp.json();
const jobs = (data.jobs || []) as Array<{
job_id: string;
library_id?: string;
}>;
if (jobs.length === 0) return;
// Label rows by library name where we can resolve one.
const libraryNames: Record<string, string> = {};
try {
const libsResp = await fetch("/api/libraries", {
headers: { Authorization: `Bearer ${token}` },
});
if (libsResp.ok) {
const libsData = await libsResp.json();
for (const lib of libsData.data || []) {
libraryNames[lib.id] = lib.name;
}
}
} catch {
// Names fall back to ids below.
}
const jobIds = jobs.map((job) => job.job_id);
const names: Record<string, string> = {};
for (const job of jobs) {
names[job.job_id] =
(job.library_id && libraryNames[job.library_id]) ||
`Library ${(job.library_id || job.job_id).slice(0, 8)}`;
}
showScanProgress(jobIds, names);
pollScanProgress(jobIds, names);
} catch (error) {
console.error("Failed to check for active scans:", error);
}
}
function showScanResults( function showScanResults(
libCount: number, libCount: number,
files: number, files: number,
@@ -389,6 +443,7 @@ document.querySelectorAll<HTMLElement>("[data-merge-source]").forEach((el) => {
}); });
export { export {
checkActiveScans,
hideScanProgress, hideScanProgress,
loadWatchStatus, loadWatchStatus,
scanAllLibraries, scanAllLibraries,
@@ -396,6 +451,7 @@ export {
}; };
Alpine.data("admin", () => ({ Alpine.data("admin", () => ({
checkActiveScans,
hideScanProgress, hideScanProgress,
loadWatchStatus, loadWatchStatus,
scanAllLibraries, scanAllLibraries,
+83
View File
@@ -1,6 +1,7 @@
import { Alpine } from "./alpine"; import { Alpine } from "./alpine";
import { showToast } from "./toast"; import { showToast } from "./toast";
import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown"; import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown";
import { renderNoteHtml } from "./markdown";
import { strToU8, zipSync } from "fflate"; import { strToU8, zipSync } from "fflate";
import { import {
buildAnnotationRows, buildAnnotationRows,
@@ -124,6 +125,14 @@ interface MetadataEditorState {
ratingText(): string; ratingText(): string;
setRating(value: number): Promise<void>; setRating(value: number): Promise<void>;
clearRating(): Promise<void>; clearRating(): Promise<void>;
hasReview: boolean;
reviewHtml: string;
reviewOpen: boolean;
reviewDraft: string;
reviewSaving: boolean;
openReviewEditor(): void;
saveReview(): Promise<void>;
deleteReview(): Promise<void>;
toggleRead(read: boolean): Promise<void>; toggleRead(read: boolean): Promise<void>;
resolveConflict(conflictId: string, winner: string): Promise<void>; resolveConflict(conflictId: string, winner: string): Promise<void>;
restoreDeletedAnnotation(annotationType: string, annotationId: string): Promise<void>; restoreDeletedAnnotation(annotationType: string, annotationId: string): Promise<void>;
@@ -157,6 +166,13 @@ Alpine.data("bookDetail", () => {
userRating: 0, userRating: 0,
ratingHover: 0, ratingHover: 0,
ratingSaving: false, ratingSaving: false,
// Review state: hasReview/reviewHtml hydrate from the server-rendered
// raw markdown in #review-raw; reviewDraft is the editor's working copy.
hasReview: false,
reviewHtml: "",
reviewOpen: false,
reviewDraft: "",
reviewSaving: false,
conflictId: "", conflictId: "",
conflictWinner: "", conflictWinner: "",
readSaving: false, readSaving: false,
@@ -467,6 +483,14 @@ Alpine.data("bookDetail", () => {
const ratingAttr = document.body.getAttribute("data-rating"); const ratingAttr = document.body.getAttribute("data-rating");
this.userRating = ratingAttr ? parseInt(ratingAttr, 10) || 0 : 0; this.userRating = ratingAttr ? parseInt(ratingAttr, 10) || 0 : 0;
// Hydrate the review card from the server-rendered raw markdown.
const reviewRaw = document.getElementById("review-raw");
if (reviewRaw) {
const source = reviewRaw.textContent || "";
this.hasReview = source.trim().length > 0;
this.reviewHtml = renderNoteHtml(source);
}
const conflictId = document.body.getAttribute("data-conflict-id"); const conflictId = document.body.getAttribute("data-conflict-id");
const conflictWinner = document.body.getAttribute("data-conflict-winner"); const conflictWinner = document.body.getAttribute("data-conflict-winner");
this.conflictId = conflictId && conflictId !== "null" ? conflictId : ""; this.conflictId = conflictId && conflictId !== "null" ? conflictId : "";
@@ -573,6 +597,65 @@ Alpine.data("bookDetail", () => {
} }
}, },
openReviewEditor() {
// Seed the draft from the CURRENT rendered source: the raw markdown
// lives in #review-raw when a review exists, otherwise start empty.
const reviewRaw = document.getElementById("review-raw");
this.reviewDraft = reviewRaw ? reviewRaw.textContent || "" : "";
this.reviewOpen = true;
},
async saveReview() {
if (this.reviewSaving) return;
this.reviewSaving = true;
const mediaId = getMediaId();
try {
const resp = await fetch(`/api/media-items/${mediaId}/review`, {
method: "PUT",
headers: {
Authorization: getAuthHeader(),
"Content-Type": "application/json",
},
body: JSON.stringify({ review: this.reviewDraft }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to save review");
}
// Reload so the server-rendered card (or its absence) matches.
window.location.reload();
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to save review",
"error",
);
this.reviewSaving = false;
}
},
async deleteReview() {
if (this.reviewSaving || !this.hasReview) return;
this.reviewSaving = true;
const mediaId = getMediaId();
try {
const resp = await fetch(`/api/media-items/${mediaId}/review`, {
method: "DELETE",
headers: { Authorization: getAuthHeader() },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to delete review");
}
window.location.reload();
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to delete review",
"error",
);
this.reviewSaving = false;
}
},
async toggleRead(read: boolean) { async toggleRead(read: boolean) {
if (this.readSaving) return; if (this.readSaving) return;
this.readSaving = true; this.readSaving = true;
+23
View File
@@ -41,6 +41,29 @@ const restoreLibrarySelection = (): void => {
}; };
const initializeScanListener = function (this: any) { const initializeScanListener = function (this: any) {
// Restore state on page load: a scan that started before this page
// was opened (or a refresh mid-scan) emits no websocket event until
// its next progress tick, so ask the server directly first.
const restoreActiveScan = async () => {
try {
const token = localStorage.getItem("token");
if (!token) return;
const resp = await fetch("/api/scanner/active-summary", {
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) return;
const data = await resp.json();
if (data.scanning) {
this.scanning = true;
this.scanProgress = Math.round((data.progress || 0) * 100);
}
} catch {
// Offline or endpoint unavailable — websocket events still drive
// the state once they arrive.
}
};
restoreActiveScan();
const handler = (message: any) => { const handler = (message: any) => {
switch (message.type) { switch (message.type) {
case "scan_progress": case "scan_progress":