"Newest episodes from my selected podcasts" is now buildable in the UI, end to end — the user could not find ANY podcast-related filter surface because none existed: the New Collection modal could only create book-scope smart collections, and the engine's episodes scope (item 3.1) had no authoring door. The scope door: - The create modal's Smart card gains a Rules-scope segmented control (📚 Books / 🎧 Podcast episodes); the copy no longer claims smart means "match books by rules". Choosing Episodes seeds an EMPTY episodes tree ({"scope":"episodes","mode":"all","children":[]}) so the collection is born episode-scoped: the editor opens with the episode vocabulary (feed/downloaded/played/published date) and membership runs the episode engine from the first rule. - ParseFilterTree now accepts an EMPTY ROOT (nested empty groups stay invalid) — the seed's legal shape. The root arrives at depth 1 by call convention, hence the depth>1 exemption. - The composers define the empty-tree semantics: zero children matches NOTHING (ComposedLiveMedia/ComposedCount guards), mirroring EvaluateSmartCollection's zero-enabled-rules convention. Without the guard the composed query degrades to the bare scope head and a fresh collection would swallow every episode in scope — pinned by TestEpisodeCollectionsEmptySeedMatchesNothing (envelope book_count 0) and TestEmptyRootTreeSeedsEpisodesScope. The curated chain order (plan §10 R2, user need: the briefing plays NEWEST-first): - view_settings.chain_order ("native"/"newest"/"oldest"/"reverse") stored on the collection — curated content, not a player-side knob. - PUT /api/collections/:id/view-settings merges the key into the stored JSONB (a blind write would clobber the playlist toggles). - buildSmartCollectionSequenceContext routes OrderEpisodes through handlers.CollectionChainOrder; the detail header's Play-as-Sequence gains the order select next to it (PlayCollectionButton), which PUTs and reloads. Pinned by chain_follows_the_curated_order (reverse flips the chain indexes and hops). Caught by the new test, a latent clobber fixed: UpdateCollection treated a NIL view_settings payload as "write NULL" — any edit that carried no settings dropped every stored key (chain_order, playlist toggles from API clients, imported provenance). Nil now means untouched, the same convention filterTree already had. Composer guards also cover ComposedCount, so the builder's live count reads the same semantics. Tests: collectionChainOrder unit matrix, empty-root parse unit pin, two integration pins (seed matches nothing; curated order drives the chain and survives whole-map edits).
401 lines
11 KiB
Go
401 lines
11 KiB
Go
package templates
|
||
|
||
import (
|
||
"time"
|
||
|
||
"github.com/a-h/templ"
|
||
)
|
||
|
||
type User struct {
|
||
ID string
|
||
Username string
|
||
Email string
|
||
Role string
|
||
Theme string
|
||
FirstName string
|
||
LastName string
|
||
CreatedAt time.Time
|
||
Token string
|
||
Timezone string
|
||
// User-level default audiobook playback speed (plan §8); 0 means
|
||
// "unset" and renders as the 1× option.
|
||
DefaultAudioSpeed float64
|
||
// Whether the web reader/player should hold a Screen Wake Lock.
|
||
KeepScreenAwake bool
|
||
// Sidebar /podcasts entry gate (plan D13): the user can see >=1
|
||
// podcasts-type library. Resolved centrally in
|
||
// getTemplateUserWithTheme so every page's Header is correct.
|
||
ShowPodcastsNav bool
|
||
}
|
||
|
||
// SpeedOption is one entry of the default-playback-speed select — the
|
||
// server-side mirror of the web player's SPEEDS list (plan §5/§8).
|
||
var SpeedOptions = []float64{0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3}
|
||
|
||
type PageData struct {
|
||
User User
|
||
CurrentPage string
|
||
}
|
||
|
||
type CollectionData struct {
|
||
ID string
|
||
Name string
|
||
Description string
|
||
Color string
|
||
Icon string
|
||
IsSystem bool
|
||
// A2c §3.3: playlist toggles (static collections only). Hidden fields
|
||
// preserve the current values on submit; absent from the create form.
|
||
HideFinished bool
|
||
ShowUpNext bool
|
||
// IsSmart marks rule-based ("dynamic") collections: membership is
|
||
// evaluated live at render, not stored in collection_items.
|
||
IsSmart bool
|
||
// MatchMode is the smart ruleset's semantics (item 2.4): "any" (OR)
|
||
// or "all" (AND). Static collections ignore it.
|
||
MatchMode string
|
||
// ChainOrder is the episode sequence's curated listening order (plan
|
||
// §10 R2): "" / native / newest / oldest / reverse — stored in
|
||
// view_settings, honored by the chain producer.
|
||
ChainOrder string
|
||
// BookCount is the LIVE count for the card (rule matches for smart
|
||
// collections, stored links for static ones).
|
||
BookCount int
|
||
}
|
||
|
||
type LibraryData struct {
|
||
ID string
|
||
Name string
|
||
Description string
|
||
TypeName string
|
||
TypeValue string
|
||
MediaCount int64
|
||
FolderCount int
|
||
// Offline is true when none of the library's folders exist on disk
|
||
// (e.g. an unmounted external drive). Contents stay intact and reappear
|
||
// when the storage returns.
|
||
Offline bool
|
||
}
|
||
|
||
type FolderData struct {
|
||
FolderPath string
|
||
}
|
||
|
||
// ArchivedItem feeds the admin archived-items page: media hidden from
|
||
// libraries because their files vanished, with their pending fate. When an
|
||
// active item with identical content (same SHA-256) exists, MatchID and
|
||
// friends are set so the UI can offer merging the archived row's reading
|
||
// data into its active twin.
|
||
type ArchivedItem struct {
|
||
ID string
|
||
Title string
|
||
Author string
|
||
LibraryName string
|
||
FilePath string
|
||
MissingScans int32
|
||
ArchivedAt *time.Time
|
||
PurgeAt *time.Time
|
||
Status string
|
||
MatchID string
|
||
MatchTitle string
|
||
MatchLibraryName string
|
||
}
|
||
|
||
type DirEntry struct {
|
||
Name string
|
||
Path string
|
||
}
|
||
|
||
type UserVisibilityData struct {
|
||
UserID string
|
||
Username string
|
||
Email string
|
||
IsVisible bool
|
||
}
|
||
|
||
type AdminStats struct {
|
||
LibraryCount int
|
||
MediaCount int
|
||
UserCount int
|
||
DeviceCount int
|
||
}
|
||
|
||
type ScanSettingsData struct {
|
||
AutoScanEnabled bool
|
||
ScanPollIntervalSeconds int
|
||
}
|
||
|
||
// SettingEntry mirrors database.SettingEntry for the admin UI. Kept as a
|
||
// template-local type so the templates package does not import database.
|
||
type SettingEntry struct {
|
||
Key string
|
||
Value string
|
||
Type string
|
||
Min string
|
||
Max string
|
||
RequiresRestart bool
|
||
Category string
|
||
Group string
|
||
Description string
|
||
IsDefault bool
|
||
}
|
||
|
||
// SettingGroup is a labeled cluster of related settings rendered as a
|
||
// sub-section within a tunable-settings card.
|
||
type SettingGroup struct {
|
||
Name string
|
||
Entries []SettingEntry
|
||
}
|
||
|
||
type SeriesCardData struct {
|
||
Name string
|
||
LibraryID string
|
||
BookCount int64
|
||
TotalInSeries int
|
||
CoverPaths []string
|
||
// Viewer's finished books in this series (drives the card's
|
||
// progress bar / "X of N read" line).
|
||
ReadCount int64
|
||
}
|
||
|
||
// SeriesPageState is the /series page's sort/filter state, echoed back
|
||
// into the toolbar controls and the pagination URLs (the URL is the
|
||
// single source of truth on this plain-GET page). Sort holds the BASE
|
||
// toolbar key (name/author/…); Dir is the effective direction.
|
||
type SeriesPageState struct {
|
||
Sort string
|
||
Dir string
|
||
Query string
|
||
Status string
|
||
}
|
||
|
||
type PendingRegistrationData struct {
|
||
RegistrationID string
|
||
DeviceName string
|
||
DeviceType string
|
||
ExpiresAt string
|
||
}
|
||
|
||
type ConflictData struct {
|
||
ID string
|
||
MediaItemID string
|
||
MediaItemTitle string
|
||
ConflictType string
|
||
ResolutionStatus string
|
||
CreatedAt string
|
||
ResolutionData map[string]interface{}
|
||
ResolvedBy string
|
||
ResolvedAt string
|
||
}
|
||
|
||
type QueueItemData struct {
|
||
ID string
|
||
DeviceID string
|
||
DeviceName string
|
||
DeviceType string
|
||
SyncType string
|
||
Status string
|
||
Priority int32
|
||
Attempts int32
|
||
CreatedAt string
|
||
}
|
||
|
||
type DeviceShelfMappingData struct {
|
||
ID string
|
||
CollectionID string
|
||
CollectionName string
|
||
DeviceShelfName string
|
||
SyncDirection string
|
||
CreatedAt string
|
||
}
|
||
|
||
type UnlinkedBookData struct {
|
||
ProgressID string
|
||
DeviceID string
|
||
DeviceType string
|
||
DeviceName string
|
||
FilePath string
|
||
SHA256 string
|
||
TitleFromDevice string
|
||
LastSyncTime string
|
||
ConfidenceScore float64
|
||
PotentialMatches []PotentialMatchData
|
||
}
|
||
|
||
type PotentialMatchData struct {
|
||
MediaItemID string
|
||
Title string
|
||
Author string
|
||
CoverImagePath string
|
||
MatchMethod string
|
||
Confidence float64
|
||
}
|
||
|
||
// Documentation types
|
||
type Navigation struct {
|
||
Sections []NavSection
|
||
}
|
||
|
||
// NavSection is a top-level documentation category. Items are documents that
|
||
// live directly in the section; Groups are subcategories (subdirectories).
|
||
type NavSection struct {
|
||
Title string
|
||
Icon string
|
||
Items []NavItem
|
||
Groups []NavGroup
|
||
Collapsed bool
|
||
}
|
||
|
||
// NavGroup is a subcategory inside a NavSection (a subdirectory of docs).
|
||
type NavGroup struct {
|
||
Title string
|
||
Icon string
|
||
Items []NavItem
|
||
Collapsed bool
|
||
}
|
||
|
||
type NavItem struct {
|
||
Title string
|
||
URL string
|
||
}
|
||
|
||
// SearchResultItem is one entry in the docs search results partial. The
|
||
// server renders it (templ); the client only swaps the markup in.
|
||
type SearchResultItem struct {
|
||
Title string
|
||
Category string
|
||
URL string
|
||
}
|
||
|
||
type Document struct {
|
||
Title string
|
||
Content string
|
||
TOC []TOCItem
|
||
Breadcrumb []BreadcrumbItem
|
||
Category string
|
||
SourceFile string
|
||
}
|
||
|
||
type TOCItem struct {
|
||
Level int
|
||
Title string
|
||
Anchor string
|
||
}
|
||
|
||
type BreadcrumbItem struct {
|
||
Title string
|
||
URL string
|
||
}
|
||
|
||
// UnsafeHTML is used for rendering raw HTML (like rendered markdown)
|
||
type UnsafeHTML string
|
||
|
||
// ToComponent converts UnsafeHTML to a templ Component
|
||
func (u UnsafeHTML) ToComponent() templ.Component {
|
||
return templ.Raw(string(u))
|
||
}
|
||
|
||
// Reader types
|
||
type ReaderMetadata struct {
|
||
MediaItemID string `json:"media_item_id"`
|
||
Title string `json:"title"`
|
||
Author string `json:"author"`
|
||
CoverImagePath string `json:"cover_image_path"`
|
||
LibraryType string `json:"library_type"`
|
||
MimeType string `json:"mime_type"`
|
||
FilePath string `json:"file_path"`
|
||
TotalPages int `json:"total_pages"`
|
||
ChapterCount int `json:"chapter_count"`
|
||
FormatGroup string `json:"format_group"`
|
||
MangaType string `json:"manga_type"`
|
||
ReadingDirection string `json:"reading_direction"`
|
||
FileURL string `json:"file_url"`
|
||
LibraryID string `json:"library_id"`
|
||
TotalCharacters int64 `json:"total_characters"`
|
||
EstimatedPages int `json:"estimated_pages"`
|
||
// TreatAsComic: fixed-layout item in a comics/manga library gets
|
||
// comic reader treatment (bookmarks only — no highlights, no search).
|
||
TreatAsComic bool `json:"treat_as_comic"`
|
||
// KeepScreenAwake: user preference — the reader holds a Screen Wake
|
||
// Lock while open when true.
|
||
KeepScreenAwake bool `json:"keep_screen_awake"`
|
||
// PodcastSequence (plan D7/Phase 2): set ONLY for podcast episodes —
|
||
// the neighboring downloaded episodes in the caller's effective
|
||
// order. Powers chain-by-default playback (auto-advance across
|
||
// episodes) and the player's episode prev/next. nil for books.
|
||
PodcastSequence *PodcastSequenceContext `json:"podcast_sequence,omitempty"`
|
||
}
|
||
|
||
// PodcastSequenceContext is the episode-level chain payload rendered
|
||
// into the audio reader's config JSON.
|
||
type PodcastSequenceContext struct {
|
||
FeedURL string `json:"feed_url,omitempty"`
|
||
FeedTitle string `json:"feed_title,omitempty"`
|
||
PrevURL string `json:"prev_url,omitempty"`
|
||
NextURL string `json:"next_url,omitempty"`
|
||
NextTitle string `json:"next_title,omitempty"`
|
||
// Source is the queue's owner (§10 R2: every producer emits the
|
||
// same shape): "feed" = the feed's effective order (per-user
|
||
// override applied), "collection" = an ordered Up Next collection
|
||
// (2.3). The player shows/hides controls by source — the order
|
||
// control is FEED-mode only, because a collection's order is
|
||
// curated content, not a listening preference.
|
||
Source string `json:"source,omitempty"`
|
||
// Feed-mode fields: FeedID powers the player's order control (it
|
||
// PUTs /api/podcasts/feeds/:id/order — the same per-user override
|
||
// the feed page writes); Order is the resolved current value that
|
||
// the dropdown reflects. Empty in collection mode.
|
||
FeedID string `json:"feed_id,omitempty"`
|
||
Order string `json:"order,omitempty"`
|
||
// EpisodeIndex/IndexTotal render the "Ep N of M" indicator (D7).
|
||
EpisodeIndex int `json:"episode_index,omitempty"`
|
||
IndexTotal int `json:"index_total,omitempty"`
|
||
}
|
||
|
||
type ReadingProgress struct {
|
||
ID string
|
||
MediaItemID string
|
||
UserID string
|
||
CurrentPage int
|
||
TotalPages int
|
||
Percentage float64
|
||
EpubCfi string
|
||
LastReadAt time.Time
|
||
Chapter int
|
||
ChapterProgress float64
|
||
FormatGroup string
|
||
}
|
||
|
||
type Bookmark struct {
|
||
ID string
|
||
MediaItemID string
|
||
UserID string
|
||
PageNumber *int
|
||
ChapterNumber *int
|
||
CfiPosition string
|
||
Title string
|
||
Position string
|
||
Notes string
|
||
CreatedAt time.Time
|
||
}
|
||
|
||
// ProcessingIssueData - Processing issue types
|
||
type ProcessingIssueData struct {
|
||
ID string
|
||
MediaItemID string
|
||
Title string
|
||
FilePath string
|
||
FormatGroup string
|
||
LibraryTypeName string
|
||
IssueType string
|
||
IssueDescription string
|
||
Severity string
|
||
CreatedAt time.Time
|
||
}
|
||
|
||
type IssueStats struct {
|
||
ErrorCount int64
|
||
WarningCount int64
|
||
InfoCount int64
|
||
}
|