Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2517baedea | ||
|
|
2117ec6181 | ||
|
|
fd6f0e3353 |
No files matched your search
@@ -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,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
|
||||||
|
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+342
-315
File diff suppressed because it is too large.
Load diff
+16
-10
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
Reference in new issue
Block a user