/api/books/match returned media metadata from ALL libraries regardless
of visibility: the resolver lookups (GetMediaItemBySHA256 and friends)
are global, so match results leaked hidden-library titles, covers and
ids to any authenticated user, and LinkBook accepted a link target from
a hidden library. Both now enforce the family-lens rule:
- QueryBooks post-filters the cascade by the requester's visible-library
set (absent-from-results — a hidden library's book simply never
matches), before determineAction so the suggested action reflects the
filtered set. BookQueryRequest gains a server-side-only
VisibleLibraryIDs field; nil keeps legacy callers unfiltered.
- LinkBook verifies the device belongs to the caller
(DeviceExistsForUser) and the target item's library is visible —
403 otherwise. Linking was previously unscoped on both axes.
- GetUnlinkedBooks likewise verifies device ownership (its service is
still a stub, but the route no longer answers for other users'
devices).
Also: the collections books-grid partial uses a light loader —
requested library validated against the visible set directly instead of
the page-only library resolution (folder reachability, counts,
dropdown payload), removing the extra queries that made library
switching hold the spinner longer.
And the dashboard library switch now folds the This Week widget's
htmx refresh behind the spinner (refreshWeekWidget awaits the widget's
swap, with a safety timeout) instead of letting it pop in after the
fade-in — the two-stage update introduced with the widget's
library-scoped re-fetch.
Guardrail: the rendering lint now counts all innerHTML assignments (not
just template-literal ones) — the old pattern undercounted 20 files at
80 sites, including .join("") builders like renderDashboardCollections.
Baseline rebalanced accordingly.
1665 lines
55 KiB
Go
1665 lines
55 KiB
Go
package router
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"time"
|
|
|
|
"bookhoard/internal/config"
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/handlers"
|
|
"bookhoard/internal/services"
|
|
"bookhoard/internal/utils"
|
|
"bookhoard/internal/version"
|
|
"bookhoard/templates"
|
|
"errors"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/labstack/echo/v5"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
func renderErrorPage(c *echo.Context, message string, errorType string) error {
|
|
var buf bytes.Buffer
|
|
err := templates.ErrorPage(message, errorType).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
log.Printf("renderErrorPage failed to render template: %v", err)
|
|
return c.HTML(http.StatusInternalServerError, "Internal server error")
|
|
}
|
|
return c.HTML(http.StatusInternalServerError, buf.String())
|
|
}
|
|
|
|
func registerFrontendRoutes(cfg *Config) {
|
|
e := cfg.Echo
|
|
|
|
// ============================================================================
|
|
// FRONTEND ROUTES - DO NOT DELETE
|
|
// These routes serve Server-Side Rendered (SSR) HTML pages for the web UI.
|
|
// They are NOT API endpoints and should NOT be removed during refactors.
|
|
// All authenticated frontend routes use the jwtMiddleware to validate tokens.
|
|
// ============================================================================
|
|
|
|
// Use existing JWT middleware (sets database user object in context)
|
|
jwtMiddleware := createJWTMiddleware(cfg)
|
|
|
|
// Protected route group for API routes
|
|
protected := e.Group("/api", jwtMiddleware)
|
|
|
|
// ============================================================================
|
|
// PUBLIC FRONTEND ROUTES (No authentication required)
|
|
// ============================================================================
|
|
|
|
// Public routes for login and registration pages
|
|
e.GET("/login", func(c *echo.Context) error {
|
|
var buf bytes.Buffer
|
|
sessionExpired := c.QueryParam("session") == "expired"
|
|
deleted := c.QueryParam("deleted") == "true"
|
|
err := templates.Login(sessionExpired, deleted, templates.SignupVisible()).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
e.GET("/register", func(c *echo.Context) error {
|
|
// Registration can be disabled by admins. The first-user exception
|
|
// keeps the page reachable on an unconfigured server (zero admins) —
|
|
// see templates.SignupVisible for the shared reasoning. Redirect
|
|
// (not 404) so stale links and bookmarks land somewhere sensible.
|
|
if !templates.SignupVisible() {
|
|
return c.Redirect(http.StatusFound, "/login")
|
|
}
|
|
var buf bytes.Buffer
|
|
err := templates.Register().Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Root route - landing page with smart login detection
|
|
e.GET("/", func(c *echo.Context) error {
|
|
var buf bytes.Buffer
|
|
var err error
|
|
|
|
tokenString := c.Request().Header.Get("Authorization")
|
|
if tokenString != "" && len(tokenString) > 7 && tokenString[:7] == "Bearer " {
|
|
tokenString = tokenString[7:]
|
|
} else {
|
|
cookie, err := c.Cookie("token")
|
|
if err == nil {
|
|
tokenString = cookie.Value
|
|
}
|
|
}
|
|
|
|
loggedIn := false
|
|
if tokenString != "" {
|
|
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
|
return []byte(cfg.Cfg.JWTSecret), nil
|
|
})
|
|
loggedIn = err == nil && token.Valid
|
|
}
|
|
|
|
err = templates.Index(loggedIn, templates.SignupVisible()).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// ============================================================================
|
|
// PROTECTED FRONTEND ROUTES (Authentication required)
|
|
// ============================================================================
|
|
|
|
// Protected frontend routes (no /api prefix)
|
|
frontendProtected := e.Group("", jwtMiddleware)
|
|
|
|
// Series browse page
|
|
frontendProtected.GET("/series", func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var errorMsg string
|
|
|
|
libRes := resolveLibrary(c, cfg, user.ID)
|
|
libraryID := libRes.LibraryID
|
|
libData := libRes.Libraries
|
|
if libRes.IsAll {
|
|
errorMsg = ""
|
|
}
|
|
|
|
perSeriesPage := 24
|
|
page := 1
|
|
if p := c.QueryParam("page"); p != "" {
|
|
if v, err := strconv.Atoi(p); err == nil && v > 0 {
|
|
page = v
|
|
}
|
|
}
|
|
offset := (page - 1) * perSeriesPage
|
|
|
|
var seriesCards []templates.SeriesCardData
|
|
totalPages := 1
|
|
|
|
if errorMsg == "" {
|
|
// Visible-library scoping: cards are grouped per library, and a
|
|
// specific library selection only renders if it's in the set.
|
|
visibleSet, visibleIDs, visErr := visibleLibraryIDsFor(c, cfg)
|
|
if visErr != nil {
|
|
log.Printf("visibleLibraryIDsFor failed: %v", visErr)
|
|
errorMsg = "Error loading series"
|
|
} else if libRes.LibUUID.Valid && !visibleSet[libRes.LibraryID] {
|
|
errorMsg = "Library not accessible"
|
|
} else {
|
|
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, visibleIDs, perSeriesPage, offset)
|
|
if err != nil {
|
|
log.Printf("GetSeriesCardsData failed: %v", err)
|
|
errorMsg = "Error loading series"
|
|
} else {
|
|
totalPages = (total + perSeriesPage - 1) / perSeriesPage
|
|
if totalPages < 1 {
|
|
totalPages = 1
|
|
}
|
|
seriesCards = make([]templates.SeriesCardData, 0, len(seriesList))
|
|
for _, s := range seriesList {
|
|
covers := s.CoverPaths
|
|
if covers == nil {
|
|
covers = []string{}
|
|
}
|
|
seriesCards = append(seriesCards, templates.SeriesCardData{
|
|
Name: s.Name,
|
|
LibraryID: s.LibraryID,
|
|
BookCount: s.BookCount,
|
|
TotalInSeries: s.TotalInSeries,
|
|
CoverPaths: covers,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if seriesCards == nil {
|
|
seriesCards = []templates.SeriesCardData{}
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.Series(user, seriesCards, libData, libraryID, totalPages, page, errorMsg).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Series detail page (books in a specific series)
|
|
frontendProtected.GET("/series/detail", func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var errorMsg string
|
|
|
|
seriesName := c.QueryParam("name")
|
|
if seriesName == "" {
|
|
return renderErrorPage(c, "Series name required", "bad_request")
|
|
}
|
|
|
|
// Series are per-library now: the detail page must say WHICH library's
|
|
// series it is showing, and the requester must be able to see it.
|
|
libraryIDParam := c.QueryParam("library_id")
|
|
if libraryIDParam == "" {
|
|
return renderErrorPage(c, "Library required", "bad_request")
|
|
}
|
|
libUUIDParam, err := uuid.Parse(libraryIDParam)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Invalid library", "bad_request")
|
|
}
|
|
visibleSet, _, visErr := visibleLibraryIDsFor(c, cfg)
|
|
if visErr != nil {
|
|
return renderErrorPage(c, "Error checking access", "access_error")
|
|
}
|
|
if !visibleSet[libraryIDParam] {
|
|
return renderErrorPage(c, "Access denied", "access_denied")
|
|
}
|
|
|
|
var bookInfoList []handlers.BookInfo
|
|
|
|
svc := services.NewSeriesService(cfg.Queries)
|
|
books, err := svc.GetSeriesBooks(c.Request().Context(), pgtype.UUID{Bytes: libUUIDParam, Valid: true}, seriesName)
|
|
if err != nil {
|
|
log.Printf("GetSeriesBooks failed: %v", err)
|
|
errorMsg = "Error loading series books"
|
|
} else {
|
|
bookInfoList = handlers.BookCardsFromMediaItems(books, handlers.LibraryTypeMap(c.Request().Context(), cfg.Queries))
|
|
}
|
|
|
|
seriesUserUUID, _ := uuid.Parse(user.ID)
|
|
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: seriesUserUUID, Valid: true}, bookInfoList)
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.BrowseDetail(user, "📚", "Series", seriesName, seriesName, "/series", "All Series", "📚", "This series doesn't have any books yet", bookInfoList, errorMsg).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
frontendProtected.GET("/tags/detail", func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var errorMsg string
|
|
|
|
tagName := c.QueryParam("name")
|
|
if tagName == "" {
|
|
return renderErrorPage(c, "Tag name required", "bad_request")
|
|
}
|
|
|
|
libRes := resolveLibrary(c, cfg, user.ID)
|
|
libraryID := libRes.LibraryID
|
|
|
|
var bookInfoList []handlers.BookInfo
|
|
|
|
if libraryID != "" && errorMsg == "" {
|
|
books, err := cfg.Queries.GetBooksByTag(c.Request().Context(), database.GetBooksByTagParams{
|
|
LibraryID: libRes.LibUUID,
|
|
Column2: tagName,
|
|
})
|
|
if err != nil {
|
|
log.Printf("GetBooksByTag failed: %v", err)
|
|
errorMsg = "Error loading tag books"
|
|
} else {
|
|
bookInfoList = handlers.BookCardsFromMediaItems(books, handlers.LibraryTypeMap(c.Request().Context(), cfg.Queries))
|
|
}
|
|
}
|
|
|
|
tagUserUUID, _ := uuid.Parse(user.ID)
|
|
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: tagUserUUID, Valid: true}, bookInfoList)
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.BrowseDetail(user, "🏷️", "Tag", tagName, tagName, "/bookshelf", "Bookshelf", "🏷️", "No books found with this tag", bookInfoList, errorMsg).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
frontendProtected.GET("/bookshelf", func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var errorMsg string
|
|
|
|
libRes := resolveLibrary(c, cfg, user.ID)
|
|
libraryID := libRes.LibraryID
|
|
libData := libRes.Libraries
|
|
|
|
// Steam Deck behavior: an offline library (all folders missing, e.g.
|
|
// an unmounted external drive) shows an empty shelf with a notice
|
|
// instead of querying items. Nothing is marked or purged.
|
|
if !libRes.IsAll {
|
|
for _, l := range libData {
|
|
if l.ID == libraryID && l.Offline {
|
|
errorMsg = l.Name + " is currently unavailable - its storage is not connected. Your books are safe and will return when the drive is reattached."
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fetch saved filters for SSR
|
|
userUUID, _ := uuid.Parse(user.ID)
|
|
var savedFilters []database.SavedFilters
|
|
savedFilters, err = cfg.Queries.GetSavedFilters(c.Request().Context(), database.GetSavedFiltersParams{
|
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
ResourceType: "media-items",
|
|
})
|
|
if err != nil {
|
|
log.Printf("GetSavedFilters failed: %v", err)
|
|
savedFilters = []database.SavedFilters{}
|
|
}
|
|
|
|
// Fetch first page of books for SSR
|
|
var bookInfoList []handlers.BookInfo
|
|
totalCount := 0
|
|
limit := 50
|
|
offset := 0
|
|
|
|
if errorMsg == "" {
|
|
// Check URL params for pagination
|
|
if limitStr := c.QueryParam("limit"); limitStr != "" {
|
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
|
limit = l
|
|
}
|
|
}
|
|
if offsetStr := c.QueryParam("offset"); offsetStr != "" {
|
|
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
|
|
offset = o
|
|
}
|
|
}
|
|
|
|
params := services.SearchParams{
|
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
LibraryID: libRes.LibUUID,
|
|
SearchQuery: "",
|
|
AuthorFilter: "",
|
|
SeriesFilter: "",
|
|
GenreFilter: "",
|
|
TagsFilter: "",
|
|
LanguageFilter: "",
|
|
YearMin: 0,
|
|
YearMax: 0,
|
|
HasCover: pgtype.Bool{Valid: false},
|
|
Sort: "created_at DESC",
|
|
Limit: limit,
|
|
Offset: offset,
|
|
}
|
|
var results []database.SearchMediaItemsUnifiedRow
|
|
results, totalCount, err = cfg.MediaHandler.ExecuteSearch(c.Request().Context(), params)
|
|
if err != nil {
|
|
log.Printf("ExecuteSearch failed: %v", err)
|
|
} else {
|
|
bookInfoList = handlers.BookCardsFromUnified(results)
|
|
}
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: userUUID, Valid: true}, bookInfoList)
|
|
err = templates.BookShelf(user, libData, libraryID, errorMsg, savedFilters, bookInfoList, limit, offset, totalCount).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Dashboard page
|
|
frontendProtected.GET("/dashboard", func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var errorMsg string
|
|
|
|
libRes := resolveLibrary(c, cfg, user.ID)
|
|
libraryID := libRes.LibraryID
|
|
|
|
userUUID, _ := uuid.Parse(user.ID)
|
|
pgLibUUID := libRes.LibUUID
|
|
|
|
prefs, err := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, pgLibUUID)
|
|
if err != nil {
|
|
log.Printf("GetDashboardPreferences failed: %v", err)
|
|
prefs = database.UserDashboardPreferences{
|
|
HiddenCollections: []string{},
|
|
CollectionOrder: []string{},
|
|
ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true},
|
|
}
|
|
}
|
|
limit := 20
|
|
if prefs.ItemsPerSection.Int32 > 0 {
|
|
limit = int(prefs.ItemsPerSection.Int32)
|
|
}
|
|
|
|
// Get ALL sections (unfiltered) for the modal
|
|
allSections, err := cfg.DashboardService.GetDashboardSections(
|
|
c.Request().Context(),
|
|
userUUID,
|
|
pgLibUUID,
|
|
limit,
|
|
prefs.CollectionOrder,
|
|
[]string{}, // No filtering - get all sections
|
|
)
|
|
if err != nil {
|
|
log.Printf("Dashboard sections query failed: %v", err)
|
|
allSections = []services.DashboardSection{}
|
|
errorMsg = "Error loading dashboard"
|
|
}
|
|
|
|
// Get only visible sections for the dashboard display
|
|
visibleSections := cfg.DashboardService.FilterHiddenCollections(allSections, prefs.HiddenCollections)
|
|
|
|
// Active library type drives the per-type wording (A2c): system
|
|
// collection titles and stat labels swap on audiobooks scope; the
|
|
// "All libraries" view keeps generic wording.
|
|
activeLibType := ""
|
|
if !libRes.IsAll {
|
|
for _, ld := range libRes.Libraries {
|
|
if ld.ID == libraryID {
|
|
activeLibType = ld.TypeValue
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
libTypes := map[string]string{}
|
|
for _, ld := range libRes.Libraries {
|
|
libTypes[ld.ID] = ld.TypeValue
|
|
}
|
|
|
|
userPgID := pgtype.UUID{Bytes: userUUID, Valid: true}
|
|
sectionData := handlers.MarkActiveConflictsSections(c.Request().Context(), cfg.Queries, userPgID, handlers.BuildSections(visibleSections, libraryID, activeLibType, libTypes))
|
|
allSectionsData := handlers.BuildSections(allSections, libraryID, activeLibType, libTypes)
|
|
|
|
// "This week" widget: the user's rolling 7-day reading pulse for
|
|
// the CURRENTLY SELECTED library (the same resolution the sections
|
|
// use), from the same session seed the /stats page serves.
|
|
// Suppressed when the service fails (dashboard always renders).
|
|
var week *templates.WeekStatsData
|
|
if cfg.StatisticsService != nil {
|
|
weekScope := pgtype.UUID{}
|
|
if !libRes.IsAll {
|
|
weekScope = libRes.LibUUID
|
|
}
|
|
weekSvc, weekErr := cfg.StatisticsService.ThisWeek(c.Request().Context(),
|
|
pgtype.UUID{Bytes: userUUID, Valid: true}, user.Timezone, weekScope)
|
|
if weekErr == nil {
|
|
wk := weekStatsData(weekSvc, libRes.LibraryID)
|
|
week = &wk
|
|
} else {
|
|
log.Printf("ThisWeek widget failed: %v", weekErr)
|
|
}
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, week, activeLibType, errorMsg).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Collections page
|
|
frontendProtected.GET("/collections", func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var errorMsg string
|
|
var collections []database.Collections
|
|
|
|
collections, err = cfg.CollectionHandler.GetCollectionsData(c)
|
|
if err != nil {
|
|
log.Printf("GetCollectionsData failed: %v", err)
|
|
collections = []database.Collections{}
|
|
errorMsg = "Error loading collections"
|
|
}
|
|
|
|
// Live per-card counts: smart collections are rule-matched at
|
|
// render against the requesting user's visible libraries; static
|
|
// ones count their stored links. Library filter applies when a
|
|
// specific library is selected.
|
|
libRes := resolveLibrary(c, cfg, user.ID)
|
|
var libScope pgtype.UUID
|
|
if !libRes.IsAll {
|
|
if parsed, parseErr := uuid.Parse(libRes.LibraryID); parseErr == nil {
|
|
libScope = pgtype.UUID{Bytes: parsed, Valid: true}
|
|
}
|
|
}
|
|
visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c)
|
|
if visErr != nil {
|
|
log.Printf("UserVisibleLibraryIDs failed: %v", visErr)
|
|
visibleIDs = nil
|
|
}
|
|
|
|
colData := make([]templates.CollectionData, len(collections))
|
|
colService := cfg.CollectionHandler.CollectionService()
|
|
for i, col := range collections {
|
|
isSmart := colService.IsSmartCollection(col.AutoAssignRules)
|
|
bookCount := 0
|
|
if isSmart {
|
|
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
|
|
colService.ParseRules(col.AutoAssignRules), services.SmartEvalOptions{
|
|
LibraryID: libScope,
|
|
VisibleIDs: visibleIDs,
|
|
})
|
|
if evalErr == nil {
|
|
bookCount = len(matches)
|
|
}
|
|
} else {
|
|
count, countErr := cfg.Queries.CountCollectionLiveItems(c.Request().Context(), database.CountCollectionLiveItemsParams{
|
|
CollectionID: pgtype.UUID{Bytes: col.ID.Bytes, Valid: true},
|
|
LibraryID: libScope,
|
|
})
|
|
if countErr == nil {
|
|
bookCount = int(count)
|
|
}
|
|
}
|
|
|
|
colData[i] = templates.CollectionData{
|
|
ID: uuid.UUID(col.ID.Bytes).String(),
|
|
Name: col.Name,
|
|
Description: utils.PgTextString(col.Description),
|
|
Color: utils.PgTextString(col.Color),
|
|
Icon: utils.PgTextString(col.Icon),
|
|
IsSystem: col.IsSystemCollection.Bool,
|
|
IsSmart: isSmart,
|
|
BookCount: bookCount,
|
|
}
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.Collection(user, colData, errorMsg).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Collection create modal
|
|
frontendProtected.GET("/collections/create-modal", func(c *echo.Context) error {
|
|
var buf bytes.Buffer
|
|
err := templates.CollectionModal(templates.CollectionData{}).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Collection edit modal
|
|
frontendProtected.GET("/collections/:id/edit-modal", func(c *echo.Context) error {
|
|
// Parse collection ID
|
|
collectionID := c.Param("id")
|
|
collUUID, err := uuid.Parse(collectionID)
|
|
if err != nil {
|
|
return c.HTML(http.StatusBadRequest, "<div>Invalid collection ID</div>")
|
|
}
|
|
|
|
// Fetch collection data
|
|
collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
|
|
if err != nil {
|
|
return c.HTML(http.StatusNotFound, "<div>Collection not found</div>")
|
|
}
|
|
|
|
// Convert to template type
|
|
colData := templates.CollectionData{
|
|
ID: uuid.UUID(collection.ID.Bytes).String(),
|
|
Name: collection.Name,
|
|
Description: utils.PgTextString(collection.Description),
|
|
Color: utils.PgTextString(collection.Color),
|
|
Icon: utils.PgTextString(collection.Icon),
|
|
}
|
|
|
|
// Ensure default color if empty
|
|
if colData.Color == "" {
|
|
colData.Color = "blue"
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.CollectionModal(colData).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Restore system collection modal
|
|
frontendProtected.GET("/collections/restore-modal", func(c *echo.Context) error {
|
|
var buf bytes.Buffer
|
|
err := templates.RestoreSystemCollectionModal().Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Smart collection rules builder page (the smart editor). User
|
|
// collections only: system sections keep their hardwired queries.
|
|
frontendProtected.GET("/collections/:id/rules", func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
collectionID := c.Param("id")
|
|
collUUID, err := uuid.Parse(collectionID)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Invalid collection ID", "invalid_id")
|
|
}
|
|
collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return renderErrorPage(c, "Collection not found", "not_found")
|
|
}
|
|
return renderErrorPage(c, "Error loading collection", "collection_load_error")
|
|
}
|
|
if collection.IsSystemCollection.Bool {
|
|
return renderErrorPage(c, "System collections cannot have rules", "not_allowed")
|
|
}
|
|
|
|
colData := templates.CollectionData{
|
|
ID: collectionID,
|
|
Name: collection.Name,
|
|
Description: utils.PgTextString(collection.Description),
|
|
Color: utils.PgTextString(collection.Color),
|
|
Icon: utils.PgTextString(collection.Icon),
|
|
IsSystem: collection.IsSystemCollection.Bool,
|
|
IsSmart: cfg.CollectionHandler.CollectionService().IsSmartCollection(collection.AutoAssignRules),
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.CollectionRules(user, colData).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Collection detail page (works for both system and user collections)
|
|
// Collection detail page. Book loading lives in loadCollectionBooks —
|
|
// shared verbatim with the books-grid partial below so the SSR page and
|
|
// the partial can never drift (rendering-ownership rule).
|
|
frontendProtected.GET("/collections/:id", func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
collectionID := c.Param("id")
|
|
collUUID, err := uuid.Parse(collectionID)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Invalid collection ID", "invalid_id")
|
|
}
|
|
collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
|
|
if err != nil {
|
|
if err.Error() == "no rows in result set" {
|
|
return renderErrorPage(c, "Collection not found", "not_found")
|
|
}
|
|
return renderErrorPage(c, "Error loading collection", "collection_load_error")
|
|
}
|
|
books, libRes, isSmart, errCode := cfg.loadCollectionBooks(c, user, collection, collectionID)
|
|
if errCode != "" {
|
|
msg := map[string]string{
|
|
"books_load_error": "Error loading books",
|
|
"invalid_library_id": "Invalid library ID",
|
|
"invalid_id": "Invalid collection ID",
|
|
}[errCode]
|
|
return renderErrorPage(c, msg, errCode)
|
|
}
|
|
colData := collectionDataFromRow(collection, collectionID, isSmart, len(books))
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.CollectionDetail(user, colData, books, libRes.LibraryID, libRes.Libraries).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Books-grid partial: the collection detail page's #books-container
|
|
// content. The library switcher swaps this in (htmx.ajax) instead of
|
|
// re-rendering the grid client-side from JSON. Family-lens scoped via
|
|
// loadCollectionBooksLite — the requested library must be in the
|
|
// caller's visible set, without the page-only library resolution.
|
|
frontendProtected.GET("/collections/:id/books-grid", func(c *echo.Context) error {
|
|
user, ok := c.Get("user").(database.Users)
|
|
if !ok {
|
|
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
|
|
}
|
|
userUUID, uuidErr := uuid.Parse(user.ID.String())
|
|
if uuidErr != nil {
|
|
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
|
|
}
|
|
collectionID := c.Param("id")
|
|
collUUID, parseErr := uuid.Parse(collectionID)
|
|
if parseErr != nil {
|
|
return c.HTML(http.StatusBadRequest, "<div>Invalid collection ID</div>")
|
|
}
|
|
collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
|
|
if err != nil {
|
|
return c.HTML(http.StatusNotFound, "<div>Collection not found</div>")
|
|
}
|
|
books, isSmart, errCode := cfg.loadCollectionBooksLite(c, userUUID, collection, collectionID)
|
|
if errCode == "library_forbidden" {
|
|
return c.HTML(http.StatusForbidden, "<div>Library not available</div>")
|
|
}
|
|
if errCode != "" {
|
|
return c.HTML(http.StatusBadRequest, "<div>Error loading books</div>")
|
|
}
|
|
colData := collectionDataFromRow(collection, collectionID, isSmart, len(books))
|
|
|
|
var buf bytes.Buffer
|
|
if renderErr := templates.CollectionBooksGrid(colData, books).Render(c.Request().Context(), &buf); renderErr != nil {
|
|
return renderErr
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Week-widget fragment: the collapsible "This Week" strip re-fetches
|
|
// itself here (htmx) whenever the library switcher dispatches
|
|
// week-refresh, carrying the current #library-select value. Same
|
|
// resolution rules as the dashboard page (family-lens safe).
|
|
frontendProtected.GET("/dashboard/week-widget", func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
|
}
|
|
if cfg.StatisticsService == nil {
|
|
return echo.NewHTTPError(http.StatusServiceUnavailable, "stats unavailable")
|
|
}
|
|
libRes := resolveLibrary(c, cfg, user.ID)
|
|
weekScope := pgtype.UUID{}
|
|
if !libRes.IsAll {
|
|
weekScope = libRes.LibUUID
|
|
}
|
|
userUUID, _ := uuid.Parse(user.ID)
|
|
weekSvc, weekErr := cfg.StatisticsService.ThisWeek(c.Request().Context(),
|
|
uuidToPGType(userUUID), user.Timezone, weekScope)
|
|
if weekErr != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, weekErr.Error())
|
|
}
|
|
var buf bytes.Buffer
|
|
widgetLibType := ""
|
|
if !libRes.IsAll {
|
|
for _, ld := range libRes.Libraries {
|
|
if ld.ID == libRes.LibraryID {
|
|
widgetLibType = ld.TypeValue
|
|
break
|
|
}
|
|
}
|
|
}
|
|
err = templates.DashboardWeekWidget(weekStatsData(weekSvc, libRes.LibraryID),
|
|
widgetLibType).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Custom Section Builder page
|
|
frontendProtected.GET("/custom-section", func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var errorMsg string
|
|
var libraries []database.GetUserVisibleLibrariesRow
|
|
|
|
userUUID, _ := uuid.Parse(user.ID)
|
|
libraries, err = cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
|
|
if err != nil {
|
|
log.Printf("GetUserVisibleLibraries failed: %v", err)
|
|
libraries = []database.GetUserVisibleLibrariesRow{}
|
|
errorMsg = "Error loading libraries"
|
|
}
|
|
|
|
libData := make([]templates.LibraryData, len(libraries))
|
|
for i, lib := range libraries {
|
|
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
|
libData[i] = templates.LibraryData{
|
|
ID: libUUID.String(),
|
|
Name: lib.Name,
|
|
Description: utils.PgTextString(lib.Description),
|
|
TypeName: lib.TypeName,
|
|
}
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.CustomSectionBuilder(user, libData, errorMsg).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Progress page
|
|
frontendProtected.GET("/progress", func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var errorMsg string
|
|
var progressData []handlers.ProgressWithMedia
|
|
|
|
progressData, err = cfg.ScannerHandler.GetAllProgressData(c)
|
|
if err != nil {
|
|
log.Printf("GetAllProgressData failed: %v", err)
|
|
progressData = []handlers.ProgressWithMedia{}
|
|
errorMsg = "Error loading progress"
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.Progress(user, progressData, errorMsg).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Devices page
|
|
frontendProtected.GET("/devices", func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var errorMsg string
|
|
var devices []handlers.DeviceInfo
|
|
var pendingList []templates.PendingRegistrationData
|
|
|
|
devices, err = cfg.DeviceHandler.GetDevicesData(c)
|
|
if err != nil {
|
|
log.Printf("GetDevicesData failed: %v", err)
|
|
devices = []handlers.DeviceInfo{}
|
|
errorMsg = "Error loading devices"
|
|
}
|
|
|
|
pendingMaps, err := cfg.DeviceHandler.GetPendingRegistrationsData(c)
|
|
if err != nil {
|
|
log.Printf("GetPendingRegistrationsData failed: %v", err)
|
|
pendingList = []templates.PendingRegistrationData{}
|
|
if errorMsg == "" {
|
|
errorMsg = "Error loading pending registrations"
|
|
}
|
|
} else {
|
|
pendingList = convertPending(pendingMaps)
|
|
}
|
|
|
|
// Get base URL from database config with fallback to config/env var
|
|
baseURL := cfg.getBaseURL(c.Request().Context())
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Conflicts page
|
|
frontendProtected.GET("/conflicts", func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var errorMsg string
|
|
var conflictsData []handlers.ConflictDetailResponse
|
|
var total, unresolved int
|
|
|
|
conflictsData, total, unresolved, err = cfg.ConflictHandler.GetConflictsData(c)
|
|
if err != nil {
|
|
log.Printf("GetConflictsData failed: %v", err)
|
|
conflictsData = []handlers.ConflictDetailResponse{}
|
|
total = 0
|
|
unresolved = 0
|
|
errorMsg = "Error loading conflicts"
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.Conflicts(user, conflictsData, total, unresolved, errorMsg).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
// Conflicts list partial: fresh list plus the stats bar as an
|
|
// out-of-band swap. The client refreshes through this instead of
|
|
// re-rendering conflict cards from JSON (rendering-ownership rule:
|
|
// templ renders, JS only swaps).
|
|
frontendProtected.GET("/conflicts/list", func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
|
|
}
|
|
conflictsData, total, unresolved, dataErr := cfg.ConflictHandler.GetConflictsData(c)
|
|
if dataErr != nil {
|
|
log.Printf("GetConflictsData failed: %v", dataErr)
|
|
conflictsData = []handlers.ConflictDetailResponse{}
|
|
total = 0
|
|
unresolved = 0
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if renderErr := templates.ConflictsRefreshFragment(user, conflictsData, total, unresolved).Render(c.Request().Context(), &buf); renderErr != nil {
|
|
return renderErr
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Analytics page → /stats (the rebuilt reading-statistics page;
|
|
// nobody bookmarks orphan pages twice).
|
|
frontendProtected.GET("/analytics", func(c *echo.Context) error {
|
|
return c.Redirect(http.StatusMovedPermanently, "/stats")
|
|
})
|
|
|
|
// Reading statistics page — per-user by default, with library
|
|
// breakdown; the per-book tier lives on the book detail page and the
|
|
// dashboard carries the "this week" widget.
|
|
frontendProtected.GET("/stats", func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
var buf bytes.Buffer
|
|
err = templates.Stats(user).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Profile page (all users)
|
|
frontendProtected.GET("/profile", func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.Profile(user).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// ============================================================================
|
|
// ADMIN FRONTEND ROUTES
|
|
// ============================================================================
|
|
|
|
// Admin routes
|
|
frontendProtected.GET("/admin", handlers.AdminMiddleware(func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
stats := getAdminStats(c.Request().Context(), cfg)
|
|
var buf bytes.Buffer
|
|
err = templates.Admin(user, stats).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
}))
|
|
|
|
frontendProtected.GET("/admin/", handlers.AdminMiddleware(func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
stats := getAdminStats(c.Request().Context(), cfg)
|
|
var buf bytes.Buffer
|
|
err = templates.Admin(user, stats).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
}))
|
|
|
|
frontendProtected.GET("/admin/archived", handlers.AdminMiddleware(func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
rows, err := cfg.Queries.ListHiddenMediaItems(c.Request().Context())
|
|
if err != nil {
|
|
log.Printf("ListHiddenMediaItems failed: %v", err)
|
|
rows = []database.ListHiddenMediaItemsRow{}
|
|
}
|
|
|
|
retentionDays := config.ArchiveRetentionDays()
|
|
items := make([]templates.ArchivedItem, 0, len(rows))
|
|
for _, row := range rows {
|
|
itemUUID, _ := uuid.FromBytes(row.ID.Bytes[0:16])
|
|
item := templates.ArchivedItem{
|
|
ID: itemUUID.String(),
|
|
Title: row.Title,
|
|
Author: utils.PgTextString(row.Author),
|
|
LibraryName: row.LibraryName,
|
|
FilePath: row.FilePath,
|
|
MissingScans: row.MissingScanCount,
|
|
}
|
|
if row.MatchID.Valid {
|
|
item.MatchID = uuid.UUID(row.MatchID.Bytes).String()
|
|
item.MatchTitle = row.MatchTitle
|
|
item.MatchLibraryName = utils.PgTextString(row.MatchLibraryName)
|
|
}
|
|
if row.ArchivedAt.Valid {
|
|
archived := row.ArchivedAt.Time
|
|
item.ArchivedAt = &archived
|
|
switch {
|
|
case retentionDays <= 0:
|
|
item.Status = "Archived - kept until manually purged (retention disabled)"
|
|
default:
|
|
purge := archived.AddDate(0, 0, retentionDays)
|
|
item.PurgeAt = &purge
|
|
item.Status = "Archived - permanently deleted " + purge.Format("Jan 2, 2006")
|
|
}
|
|
} else {
|
|
item.Status = fmt.Sprintf("Missing from disk - archives on the next scan (%d/2)", row.MissingScanCount)
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := templates.AdminArchived(user, items, retentionDays).Render(c.Request().Context(), &buf); err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
}))
|
|
|
|
frontendProtected.GET("/admin/library", handlers.AdminMiddleware(func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
// Fetch all libraries (admin-only)
|
|
libraries, err := cfg.LibraryHandler.ListLibrariesData(c.Request().Context())
|
|
if err != nil {
|
|
log.Printf("ListLibrariesData failed: %v", err)
|
|
libraries = []database.ListLibrariesRow{}
|
|
}
|
|
|
|
// Convert to template LibraryData
|
|
libData := make([]templates.LibraryData, len(libraries))
|
|
for i, lib := range libraries {
|
|
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
|
folders, _ := cfg.LibraryService.GetLibraryFolders(c.Request().Context(), lib.ID)
|
|
libData[i] = templates.LibraryData{
|
|
ID: libUUID.String(),
|
|
Name: lib.Name,
|
|
Description: utils.PgTextString(lib.Description),
|
|
TypeName: lib.TypeName,
|
|
TypeValue: lib.TypeName,
|
|
FolderCount: len(folders),
|
|
}
|
|
}
|
|
|
|
// Fetch all users for visibility management
|
|
users, err := cfg.Queries.ListUsers(c.Request().Context())
|
|
if err != nil {
|
|
log.Printf("ListUsers failed: %v", err)
|
|
users = []database.ListUsersRow{}
|
|
}
|
|
|
|
// Convert to template User types
|
|
userData := make([]templates.User, len(users))
|
|
for i, u := range users {
|
|
userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16])
|
|
userData[i] = templates.User{
|
|
ID: userUUID.String(),
|
|
Username: u.Username,
|
|
Email: u.Email,
|
|
Role: u.Role,
|
|
}
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
archivedCount, _ := cfg.Queries.CountArchivedMediaItems(c.Request().Context())
|
|
err = templates.AdminLibrary(user, libData, userData, archivedCount).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
}))
|
|
|
|
frontendProtected.GET("/admin/libraries/:id/issues", handlers.AdminMiddleware(func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
libraryID := c.Param("id")
|
|
libUUID, err := uuid.Parse(libraryID)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Invalid library ID", "invalid_id")
|
|
}
|
|
// Fetch stats
|
|
stats, err := cfg.Queries.GetProcessingIssueStats(
|
|
c.Request().Context(),
|
|
uuidToPGType(libUUID),
|
|
)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading stats", "stats_load_error")
|
|
}
|
|
// Fetch issues
|
|
dbIssues, err := cfg.Queries.ListProcessingIssuesByLibrary(
|
|
c.Request().Context(),
|
|
uuidToPGType(libUUID),
|
|
)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading issues", "issues_load_error")
|
|
}
|
|
// Convert to template data
|
|
issues := make([]templates.ProcessingIssueData, len(dbIssues))
|
|
for i, issue := range dbIssues {
|
|
issueUUID, _ := uuid.FromBytes(issue.ID.Bytes[0:16])
|
|
issues[i] = templates.ProcessingIssueData{
|
|
ID: issueUUID.String(),
|
|
MediaItemID: uuid.UUID(issue.MediaItemID.Bytes).String(),
|
|
Title: issue.Title,
|
|
FilePath: issue.FilePath,
|
|
FormatGroup: issue.FormatGroup,
|
|
LibraryTypeName: issue.LibraryTypeName,
|
|
IssueType: issue.IssueType,
|
|
IssueDescription: issue.IssueDescription,
|
|
Severity: issue.Severity,
|
|
CreatedAt: issue.CreatedAt.Time,
|
|
}
|
|
}
|
|
statsData := templates.IssueStats{
|
|
ErrorCount: stats.ErrorCount,
|
|
WarningCount: stats.WarningCount,
|
|
InfoCount: stats.InfoCount,
|
|
}
|
|
var buf bytes.Buffer
|
|
err = templates.AdminProcessingIssues(user, libraryID, issues, statsData).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
}))
|
|
|
|
// Admin hash conflicts page: content-duplicate groups flagged during hash
|
|
// backfill or rescan, resolved by keeping all copies or merging into one.
|
|
frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
pending, err := cfg.Queries.ListPendingHashConflicts(c.Request().Context())
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading hash conflicts", "conflicts_load_error")
|
|
}
|
|
|
|
conflicts := make([]templates.HashConflictData, 0, len(pending))
|
|
for _, p := range pending {
|
|
conflict := templates.HashConflictData{
|
|
ID: uuid.UUID(p.ID.Bytes).String(),
|
|
LibraryName: p.LibraryName,
|
|
SHA256: p.FileSha256,
|
|
SHAShort: p.FileSha256[:16] + "…",
|
|
CreatedAt: p.CreatedAt.Time.In(utils.UserLocation(user.Timezone)).Format("Jan 2, 2006"),
|
|
Items: []templates.HashConflictItemData{},
|
|
}
|
|
|
|
items, err := cfg.Queries.ListMediaItemsBySHA256AndLibrary(c.Request().Context(), database.ListMediaItemsBySHA256AndLibraryParams{
|
|
FileSha256: pgtype.Text{String: p.FileSha256, Valid: true},
|
|
LibraryID: p.LibraryID,
|
|
})
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
for _, mi := range items {
|
|
counts, totalData := handlers.MediaItemUsageCountsTotal(c.Request().Context(), cfg.Queries, mi.ID)
|
|
conflict.Items = append(conflict.Items, templates.HashConflictItemData{
|
|
ID: uuid.UUID(mi.ID.Bytes).String(),
|
|
Title: mi.Title,
|
|
Author: mi.Author.String,
|
|
FilePath: mi.FilePath,
|
|
FileSize: mi.FileSize.Int64,
|
|
UsageSummary: fmt.Sprintf("%d progress, %d highlights, %d bookmarks, %d notes, %d collections",
|
|
counts.ProgressCount, counts.HighlightsCount, counts.BookmarksCount, counts.NotesCount, counts.CollectionsCount),
|
|
HasReadingData: totalData > 0,
|
|
})
|
|
}
|
|
|
|
conflicts = append(conflicts, conflict)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.AdminHashConflicts(user, conflicts).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
}))
|
|
|
|
// Admin users page
|
|
frontendProtected.GET("/admin/users", handlers.AdminMiddleware(func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
// Fetch all users
|
|
users, err := cfg.Queries.ListUsers(c.Request().Context())
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading users", "users_load_error")
|
|
}
|
|
|
|
// Count admins for UI protection
|
|
adminCount := 0
|
|
for _, u := range users {
|
|
if u.Role == "admin" {
|
|
adminCount++
|
|
}
|
|
}
|
|
|
|
// Convert to template users
|
|
templateUsers := make([]templates.User, len(users))
|
|
for i, u := range users {
|
|
templateUsers[i] = templates.User{
|
|
ID: uuid.UUID(u.ID.Bytes).String(),
|
|
Username: u.Username,
|
|
Email: u.Email,
|
|
Role: u.Role,
|
|
Theme: utils.PgTextString(u.Theme),
|
|
FirstName: utils.PgTextString(u.FirstName),
|
|
LastName: utils.PgTextString(u.LastName),
|
|
CreatedAt: u.CreatedAt.Time,
|
|
}
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.AdminUsers(user, templateUsers, adminCount).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
}))
|
|
|
|
// Admin: Get profile modal for editing user
|
|
frontendProtected.GET("/admin/settings", handlers.AdminMiddleware(func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
ctx := c.Request().Context()
|
|
|
|
// Fetch current system configuration - just base_url
|
|
baseURL := cfg.getBaseURL(ctx)
|
|
|
|
systemConfig := map[string]string{
|
|
"base_url": baseURL,
|
|
"default_timezone": "UTC",
|
|
}
|
|
|
|
defaultTimezone, err := cfg.Queries.GetSystemTimezone(ctx)
|
|
if err == nil && defaultTimezone != "" {
|
|
systemConfig["default_timezone"] = defaultTimezone
|
|
}
|
|
|
|
// Fetch scan settings
|
|
scanSettings := templates.ScanSettingsData{
|
|
AutoScanEnabled: true,
|
|
ScanPollIntervalSeconds: 60,
|
|
}
|
|
if val, err := cfg.Queries.GetSystemSetting(ctx, "auto_scan_enabled"); err == nil {
|
|
scanSettings.AutoScanEnabled = val == "true"
|
|
}
|
|
if val, err := cfg.Queries.GetSystemSetting(ctx, "scan_poll_interval_seconds"); err == nil {
|
|
if n, err := strconv.Atoi(val); err == nil {
|
|
scanSettings.ScanPollIntervalSeconds = n
|
|
}
|
|
}
|
|
|
|
// Load tunable settings entries from the registry. Exclude keys that
|
|
// already have their own dedicated UI cards (timezone dropdown, scan
|
|
// settings) so they aren't listed twice.
|
|
dedicatedUI := map[string]bool{
|
|
"default_timezone": true,
|
|
"scan_poll_interval_seconds": true,
|
|
"auto_scan_enabled": true,
|
|
}
|
|
var tunableSettings []templates.SettingEntry
|
|
if cfg.Settings != nil {
|
|
for _, e := range cfg.Settings.All() {
|
|
if dedicatedUI[e.Key] {
|
|
continue
|
|
}
|
|
tunableSettings = append(tunableSettings, templates.SettingEntry{
|
|
Key: e.Key,
|
|
Value: e.Value,
|
|
Type: e.Type,
|
|
Min: e.Min,
|
|
Max: e.Max,
|
|
RequiresRestart: e.RequiresRestart,
|
|
Category: e.Category,
|
|
Group: e.Group,
|
|
Description: e.Description,
|
|
IsDefault: e.IsDefault,
|
|
})
|
|
}
|
|
}
|
|
liveGroups, restartGroups := templates.GroupTunableSettings(tunableSettings)
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.AdminSettings(user, systemConfig, scanSettings, liveGroups, restartGroups, "").Render(ctx, &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
}))
|
|
|
|
frontendProtected.GET("/admin/users/:id/profile-modal", handlers.AdminMiddleware(func(c *echo.Context) error {
|
|
// Get target user ID from URL
|
|
targetUserID := c.Param("id")
|
|
parsedUUID, err := uuid.Parse(targetUserID)
|
|
if err != nil {
|
|
return c.HTML(http.StatusBadRequest, "<div>Invalid user ID</div>")
|
|
}
|
|
|
|
// Fetch target user
|
|
targetUser, err := cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(parsedUUID))
|
|
if err != nil {
|
|
return c.HTML(http.StatusNotFound, "<div>User not found</div>")
|
|
}
|
|
|
|
// Convert to template user
|
|
templateUser := templates.User{
|
|
ID: uuid.UUID(targetUser.ID.Bytes).String(),
|
|
Username: targetUser.Username,
|
|
Email: targetUser.Email,
|
|
Role: targetUser.Role,
|
|
Theme: utils.PgTextString(targetUser.Theme),
|
|
FirstName: utils.PgTextString(targetUser.FirstName),
|
|
LastName: utils.PgTextString(targetUser.LastName),
|
|
CreatedAt: targetUser.CreatedAt.Time,
|
|
// Without this the timezone select rendered UTC selected for
|
|
// EVERY user — saves landed, the modal just never showed them.
|
|
Timezone: utils.PgTextString(targetUser.Timezone),
|
|
}
|
|
|
|
// Render modal
|
|
var buf bytes.Buffer
|
|
err = templates.ProfileModal(templateUser).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
}))
|
|
|
|
// Admin library HTMX endpoints
|
|
registerAdminLibraryRoutes(cfg, frontendProtected)
|
|
// Admin settings HTMX endpoints
|
|
registerAdminSettingsRoutes(cfg, frontendProtected)
|
|
|
|
// ============================================================================
|
|
// LEGACY API ROUTES (for backward compatibility)
|
|
// ============================================================================
|
|
|
|
// Keep legacy routes under /api for existing API consumers
|
|
protected.GET("/devices-page", func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
var errorMsg string
|
|
var devices []handlers.DeviceInfo
|
|
var pendingList []templates.PendingRegistrationData
|
|
|
|
devices, err = cfg.DeviceHandler.GetDevicesData(c)
|
|
if err != nil {
|
|
log.Printf("GetDevicesData failed: %v", err)
|
|
devices = []handlers.DeviceInfo{}
|
|
errorMsg = "Error loading devices"
|
|
}
|
|
|
|
pendingMaps, err := cfg.DeviceHandler.GetPendingRegistrationsData(c)
|
|
if err != nil {
|
|
log.Printf("GetPendingRegistrationsData failed: %v", err)
|
|
pendingList = []templates.PendingRegistrationData{}
|
|
if errorMsg == "" {
|
|
errorMsg = "Error loading pending registrations"
|
|
}
|
|
} else {
|
|
pendingList = convertPending(pendingMaps)
|
|
}
|
|
|
|
// Get base URL from database config with fallback to config/env var
|
|
baseURL := cfg.getBaseURL(c.Request().Context())
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
protected.GET("/conflicts-page", func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var errorMsg string
|
|
var conflictsData []handlers.ConflictDetailResponse
|
|
var total, unresolved int
|
|
|
|
conflictsData, total, unresolved, err = cfg.ConflictHandler.GetConflictsData(c)
|
|
if err != nil {
|
|
log.Printf("GetConflictsData failed: %v", err)
|
|
conflictsData = []handlers.ConflictDetailResponse{}
|
|
total = 0
|
|
unresolved = 0
|
|
errorMsg = "Error loading conflicts"
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.Conflicts(user, conflictsData, total, unresolved, errorMsg).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Book detail page
|
|
frontendProtected.GET("/media/:uuid", func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
// Parse media UUID from URL
|
|
mediaUUID, err := uuid.Parse(c.Param("uuid"))
|
|
if err != nil {
|
|
return renderErrorPage(c, "Invalid media ID", "invalid_id")
|
|
}
|
|
pgMediaUUID := uuidToPGType(mediaUUID)
|
|
|
|
// Get user UUID for queries
|
|
userUUID, _ := uuid.Parse(user.ID)
|
|
pgUserID := uuidToPGType(userUUID)
|
|
|
|
// Fetch media item (embeds ALL metadata)
|
|
mediaItem, err := cfg.Queries.GetMediaItem(c.Request().Context(), pgMediaUUID)
|
|
if err != nil {
|
|
if err.Error() == "no rows in result set" {
|
|
return renderErrorPage(c, "Book not found", "not_found")
|
|
}
|
|
return renderErrorPage(c, "Error loading book", "database_error")
|
|
}
|
|
|
|
// Resolve cover image path
|
|
if mediaItem.CoverImagePath.Valid && mediaItem.CoverImagePath.String != "" {
|
|
resolvedPath := utils.ResolveMediaURL(mediaItem.LibraryID, mediaItem.CoverImagePath)
|
|
mediaItem.CoverImagePath = pgtype.Text{String: resolvedPath, Valid: true}
|
|
}
|
|
|
|
// Resolve the on-disk location (library folder + relative path) so the
|
|
// detail page can show where the file lives, even for sparse metadata.
|
|
// Admin-only: everyday users never receive the absolute path.
|
|
fileLocation := ""
|
|
if user.Role == "admin" {
|
|
fileLocation = mediaItem.FilePath
|
|
if folders, ferr := cfg.Queries.GetLibraryFolders(c.Request().Context(), mediaItem.LibraryID); ferr == nil {
|
|
for _, folder := range folders {
|
|
candidate := filepath.Join(folder.FolderPath, mediaItem.FilePath)
|
|
if _, serr := os.Stat(candidate); serr == nil {
|
|
fileLocation = candidate
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fetch rating
|
|
var rating *database.MediaRatings
|
|
userRating, err := cfg.Queries.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{
|
|
MediaItemID: pgMediaUUID,
|
|
UserID: pgUserID,
|
|
})
|
|
if err == nil {
|
|
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
|
|
collections, _ := cfg.Queries.GetCollectionsForBook(c.Request().Context(), pgMediaUUID)
|
|
|
|
// Fetch reading progress
|
|
var progress *database.ReadingProgress
|
|
readingProgress, err := cfg.Queries.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
|
|
MediaItemID: pgMediaUUID,
|
|
UserID: pgUserID,
|
|
})
|
|
if err == nil {
|
|
progress = &readingProgress
|
|
}
|
|
|
|
// Fetch active conflict (if any)
|
|
var activeConflict *handlers.ConflictDetailResponse
|
|
conflicts, err := cfg.Queries.ListSyncConflictsByMediaItem(c.Request().Context(),
|
|
database.ListSyncConflictsByMediaItemParams{
|
|
MediaItemID: pgMediaUUID,
|
|
UserID: pgUserID,
|
|
})
|
|
if err == nil && len(conflicts) > 0 {
|
|
for _, conf := range conflicts {
|
|
if conf.ResolutionStatus.Valid && conf.ResolutionStatus.String == "unresolved" {
|
|
var conflictData map[string]handlers.ConflictSourceData
|
|
if err := json.Unmarshal(conf.ConflictData, &conflictData); err == nil {
|
|
detail := handlers.NewConflictDetail(conf.ID, conf.MediaItemID,
|
|
mediaItem.Title, conf.ConflictType, conf.ResolutionStatus.String,
|
|
conflictData, conf.CreatedAt, conf.ResolvedAt, conf.ResolvedBy,
|
|
conf.ResolutionData)
|
|
activeConflict = &detail
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Count notes and highlights
|
|
notes, _ := cfg.Queries.GetMediaNotes(c.Request().Context(), database.GetMediaNotesParams{
|
|
MediaItemID: pgMediaUUID,
|
|
UserID: pgUserID,
|
|
})
|
|
highlights, _ := cfg.Queries.GetMediaHighlights(c.Request().Context(), database.GetMediaHighlightsParams{
|
|
MediaItemID: pgMediaUUID,
|
|
UserID: pgUserID,
|
|
})
|
|
|
|
// Per-book reading stats (the viewer's own history card) — nil
|
|
// keeps the card off the page for untouched books / other users.
|
|
var bookStats *handlers.BookStatsData
|
|
if cfg.StatisticsService != nil {
|
|
if svcStats, serr := cfg.StatisticsService.BookStats(c.Request().Context(),
|
|
pgUserID, mediaUUID, user.Timezone); serr == nil && svcStats.SessionCount > 0 {
|
|
bookStats = &handlers.BookStatsData{
|
|
TotalMinutes: svcStats.TotalMinutes,
|
|
TotalSeconds: svcStats.TotalSeconds,
|
|
TotalPages: svcStats.TotalPages,
|
|
SessionCount: svcStats.SessionCount,
|
|
MaxProgress: svcStats.MaxProgress,
|
|
Finished: svcStats.Finished,
|
|
FirstSession: svcStats.FirstSession,
|
|
LastSession: svcStats.LastSession,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Assemble response (no field duplication!)
|
|
detail := handlers.MediaDetail{
|
|
MediaItems: mediaItem, // Embedded - ALL fields available
|
|
FileLocation: fileLocation,
|
|
Rating: rating,
|
|
Review: review,
|
|
Collections: collections,
|
|
ReadingProgress: progress,
|
|
BookStats: bookStats,
|
|
ActiveConflict: activeConflict,
|
|
NotesCount: len(notes),
|
|
HighlightsCount: len(highlights),
|
|
DeletedAnnotations: handlers.DeletedAnnotationsForBook(c.Request().Context(), cfg.Queries, pgUserID, pgMediaUUID),
|
|
}
|
|
|
|
// Render template
|
|
var buf bytes.Buffer
|
|
// The deleted-annotations modal states the retention in plain days
|
|
// (the tunable's user-facing value), never as a settings name.
|
|
tombstoneDays := cfg.Settings.TombstoneTTL() / (24 * time.Hour)
|
|
err = templates.BookDetail(user, detail, "", int(tombstoneDays)).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
e.GET("/health", cfg.GetHealth)
|
|
}
|
|
|
|
// ============================================================================
|
|
// HEALTH CHECK
|
|
// ============================================================================
|
|
|
|
// Health check
|
|
func (cfg *Config) GetHealth(c *echo.Context) error {
|
|
ctx, cancel := context.WithTimeout(c.Request().Context(), 2*time.Second)
|
|
defer cancel()
|
|
if err := pingDB(cfg, ctx); err != nil {
|
|
return c.JSON(http.StatusServiceUnavailable, map[string]string{
|
|
"status": "unhealthy",
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"status": "healthy",
|
|
"version": version.Version,
|
|
"database": "connected",
|
|
"scan": map[string]interface{}{
|
|
"scan_in_progress": cfg.Worker.HasActiveScans(),
|
|
"active_jobs": cfg.Worker.GetActiveJobCount(),
|
|
},
|
|
})
|
|
}
|
|
|
|
// weekStatsData maps the statistics weekly aggregate to the widget's
|
|
// template shape (shared by the dashboard page and the HTMX
|
|
// week-widget fragment).
|
|
func weekStatsData(weekSvc services.WeekSummaryStats, libraryID string) templates.WeekStatsData {
|
|
return templates.WeekStatsData{
|
|
WeekMinutes: weekSvc.WeekMinutes,
|
|
WeekPages: weekSvc.WeekPages,
|
|
WeekSessions: weekSvc.WeekSessions,
|
|
WeekBooks: weekSvc.WeekBooks,
|
|
PrevMinutes: weekSvc.PrevMinutes,
|
|
ActiveToday: weekSvc.ActiveToday,
|
|
CurrentStreak: weekSvc.CurrentStreak,
|
|
LibraryID: libraryID,
|
|
}
|
|
}
|