feat: ratings, shelves, sleep timer, EPUB export + fix TS errors
All checks were successful
Release / Docker / caddy (push) Successful in 38s
Release / Check ui (push) Successful in 39s
Release / Test backend (push) Successful in 57s
Release / Docker / ui (push) Successful in 1m49s
Release / Docker / runner (push) Successful in 3m12s
Release / Docker / backend (push) Successful in 3m46s
Release / Gitea Release (push) Successful in 13s
All checks were successful
Release / Docker / caddy (push) Successful in 38s
Release / Check ui (push) Successful in 39s
Release / Test backend (push) Successful in 57s
Release / Docker / ui (push) Successful in 1m49s
Release / Docker / runner (push) Successful in 3m12s
Release / Docker / backend (push) Successful in 3m46s
Release / Gitea Release (push) Successful in 13s
**Ratings (1–5 stars)**
- New `book_ratings` PB collection (session_id, user_id, slug, rating)
- `getBookRating`, `getBookAvgRating`, `setBookRating` in pocketbase.ts
- GET/POST /api/ratings/[slug] API route
- StarRating.svelte component with hover, animated stars, avg display
- Star rating shown on book detail page (desktop + mobile)
**Plan-to-Read shelf**
- `shelf` field added to `user_library` (reading/plan_to_read/completed/dropped)
- `updateBookShelf`, `getShelfMap` in pocketbase.ts
- PATCH /api/library/[slug] for shelf updates
- Shelf selector dropdown on book detail page (only when saved)
- Shelf tabs on library page to filter by category
**Sleep timer**
- `sleepUntil` state added to AudioStore
- Layout handles timer lifecycle (survives chapter navigation)
- Cycles Off → 15m → 30m → 45m → 60m → Off
- Shows live countdown in AudioPlayer when active
**EPUB export**
- Go backend: GET /api/export/{slug}?from=N&to=N
- Generates valid EPUB2 zip (mimetype uncompressed, OPF, NCX, XHTML chapters)
- Markdown → HTML via goldmark
- SvelteKit proxy at /api/export/[slug]
- Download button on book detail page (only when in library)
**Fix TS errors**
- discover/+page.svelte: currentBook possibly undefined (use {@const book})
- cardEl now $state for reactive binding
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
143
backend/internal/backend/epub.go
Normal file
143
backend/internal/backend/epub.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type epubChapter struct {
|
||||
Number int
|
||||
Title string
|
||||
HTML string
|
||||
}
|
||||
|
||||
func generateEPUB(slug, title, author string, chapters []epubChapter) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
w := zip.NewWriter(&buf)
|
||||
|
||||
// 1. mimetype — MUST be first, MUST be uncompressed (Store method)
|
||||
mw, err := w.CreateHeader(&zip.FileHeader{
|
||||
Name: "mimetype",
|
||||
Method: zip.Store,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mw.Write([]byte("application/epub+zip"))
|
||||
|
||||
// 2. META-INF/container.xml
|
||||
addFile(w, "META-INF/container.xml", containerXML())
|
||||
|
||||
// 3. OEBPS/style.css
|
||||
addFile(w, "OEBPS/style.css", epubCSS())
|
||||
|
||||
// 4. OEBPS/content.opf
|
||||
addFile(w, "OEBPS/content.opf", contentOPF(slug, title, author, chapters))
|
||||
|
||||
// 5. OEBPS/toc.ncx
|
||||
addFile(w, "OEBPS/toc.ncx", tocNCX(slug, title, chapters))
|
||||
|
||||
// 6. Chapter files
|
||||
for _, ch := range chapters {
|
||||
name := fmt.Sprintf("OEBPS/chapter-%04d.xhtml", ch.Number)
|
||||
addFile(w, name, chapterXHTML(ch))
|
||||
}
|
||||
|
||||
w.Close()
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func addFile(w *zip.Writer, name, content string) {
|
||||
f, _ := w.Create(name)
|
||||
f.Write([]byte(content))
|
||||
}
|
||||
|
||||
func containerXML() string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>`
|
||||
}
|
||||
|
||||
func contentOPF(slug, title, author string, chapters []epubChapter) string {
|
||||
var items, spine strings.Builder
|
||||
for _, ch := range chapters {
|
||||
id := fmt.Sprintf("ch%04d", ch.Number)
|
||||
href := fmt.Sprintf("chapter-%04d.xhtml", ch.Number)
|
||||
items.WriteString(fmt.Sprintf(` <item id="%s" href="%s" media-type="application/xhtml+xml"/>`+"\n", id, href))
|
||||
spine.WriteString(fmt.Sprintf(` <itemref idref="%s"/>`+"\n", id))
|
||||
}
|
||||
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="uid" version="2.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>%s</dc:title>
|
||||
<dc:creator>%s</dc:creator>
|
||||
<dc:identifier id="uid">%s</dc:identifier>
|
||||
<dc:language>en</dc:language>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
|
||||
<item id="css" href="style.css" media-type="text/css"/>
|
||||
%s </manifest>
|
||||
<spine toc="ncx">
|
||||
%s </spine>
|
||||
</package>`, escapeXML(title), escapeXML(author), slug, items.String(), spine.String())
|
||||
}
|
||||
|
||||
func tocNCX(slug, title string, chapters []epubChapter) string {
|
||||
var points strings.Builder
|
||||
for i, ch := range chapters {
|
||||
chTitle := ch.Title
|
||||
if chTitle == "" {
|
||||
chTitle = fmt.Sprintf("Chapter %d", ch.Number)
|
||||
}
|
||||
points.WriteString(fmt.Sprintf(` <navPoint id="np%d" playOrder="%d">
|
||||
<navLabel><text>%s</text></navLabel>
|
||||
<content src="chapter-%04d.xhtml"/>
|
||||
</navPoint>`+"\n", i+1, i+1, escapeXML(chTitle), ch.Number))
|
||||
}
|
||||
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE ncx PUBLIC "-//NISO//DTD ncx 2005-1//EN" "http://www.daisy.org/z3986/2005/ncx-2005-1.dtd">
|
||||
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
|
||||
<head><meta name="dtb:uid" content="%s"/></head>
|
||||
<docTitle><text>%s</text></docTitle>
|
||||
<navMap>
|
||||
%s </navMap>
|
||||
</ncx>`, slug, escapeXML(title), points.String())
|
||||
}
|
||||
|
||||
func chapterXHTML(ch epubChapter) string {
|
||||
title := ch.Title
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("Chapter %d", ch.Number)
|
||||
}
|
||||
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head><title>%s</title><link rel="stylesheet" href="style.css"/></head>
|
||||
<body>
|
||||
<h1 class="chapter-title">%s</h1>
|
||||
%s
|
||||
</body>
|
||||
</html>`, escapeXML(title), escapeXML(title), ch.HTML)
|
||||
}
|
||||
|
||||
func epubCSS() string {
|
||||
return `body { font-family: Georgia, serif; font-size: 1em; line-height: 1.6; margin: 1em 2em; }
|
||||
h1.chapter-title { font-size: 1.4em; margin-bottom: 1em; }
|
||||
p { margin: 0 0 0.8em 0; text-indent: 1.5em; }
|
||||
p:first-of-type { text-indent: 0; }
|
||||
`
|
||||
}
|
||||
|
||||
func escapeXML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
return s
|
||||
}
|
||||
@@ -1729,6 +1729,109 @@ func stripMarkdown(src string) string {
|
||||
return strings.TrimSpace(src)
|
||||
}
|
||||
|
||||
// ── EPUB export ───────────────────────────────────────────────────────────────
|
||||
|
||||
// handleExportEPUB handles GET /api/export/{slug}.
|
||||
// Generates and streams an EPUB file for the book identified by slug.
|
||||
// Optional query params: from=N&to=N to limit the chapter range (default: all).
|
||||
func (s *Server) handleExportEPUB(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "missing slug")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
// Parse optional from/to range.
|
||||
fromStr := r.URL.Query().Get("from")
|
||||
toStr := r.URL.Query().Get("to")
|
||||
fromN, toN := 0, 0
|
||||
if fromStr != "" {
|
||||
v, err := strconv.Atoi(fromStr)
|
||||
if err != nil || v < 1 {
|
||||
jsonError(w, http.StatusBadRequest, "invalid 'from' param")
|
||||
return
|
||||
}
|
||||
fromN = v
|
||||
}
|
||||
if toStr != "" {
|
||||
v, err := strconv.Atoi(toStr)
|
||||
if err != nil || v < 1 {
|
||||
jsonError(w, http.StatusBadRequest, "invalid 'to' param")
|
||||
return
|
||||
}
|
||||
toN = v
|
||||
}
|
||||
|
||||
// Fetch book metadata for title and author.
|
||||
meta, inLib, err := s.deps.BookReader.ReadMetadata(ctx, slug)
|
||||
if err != nil || !inLib {
|
||||
s.deps.Log.Warn("handleExportEPUB: book not found", "slug", slug, "err", err)
|
||||
jsonError(w, http.StatusNotFound, "book not found")
|
||||
return
|
||||
}
|
||||
|
||||
// List all chapters.
|
||||
chapters, err := s.deps.BookReader.ListChapters(ctx, slug)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleExportEPUB: ListChapters failed", "slug", slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to list chapters")
|
||||
return
|
||||
}
|
||||
|
||||
// Filter chapters by from/to range.
|
||||
var filtered []epubChapter
|
||||
for _, ch := range chapters {
|
||||
if fromN > 0 && ch.Number < fromN {
|
||||
continue
|
||||
}
|
||||
if toN > 0 && ch.Number > toN {
|
||||
continue
|
||||
}
|
||||
|
||||
// Fetch markdown from MinIO.
|
||||
mdText, readErr := s.deps.BookReader.ReadChapter(ctx, slug, ch.Number)
|
||||
if readErr != nil {
|
||||
s.deps.Log.Warn("handleExportEPUB: ReadChapter failed", "slug", slug, "n", ch.Number, "err", readErr)
|
||||
// Skip chapters that cannot be fetched.
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert markdown to HTML using goldmark.
|
||||
md := goldmark.New()
|
||||
var htmlBuf bytes.Buffer
|
||||
if convErr := md.Convert([]byte(mdText), &htmlBuf); convErr != nil {
|
||||
htmlBuf.Reset()
|
||||
htmlBuf.WriteString("<p>" + mdText + "</p>")
|
||||
}
|
||||
|
||||
filtered = append(filtered, epubChapter{
|
||||
Number: ch.Number,
|
||||
Title: ch.Title,
|
||||
HTML: htmlBuf.String(),
|
||||
})
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
jsonError(w, http.StatusNotFound, "no chapters found in the requested range")
|
||||
return
|
||||
}
|
||||
|
||||
epubBytes, err := generateEPUB(slug, meta.Title, meta.Author, filtered)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleExportEPUB: generateEPUB failed", "slug", slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to generate EPUB")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/epub+zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.epub"`, slug))
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(epubBytes)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(epubBytes)
|
||||
}
|
||||
|
||||
// ── Hardcoded Kokoro voice fallback ───────────────────────────────────────────
|
||||
|
||||
// kokoroVoiceIDs is the built-in fallback list of Kokoro voice IDs used when
|
||||
|
||||
@@ -190,6 +190,9 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux.HandleFunc("GET /api/presign/avatar/{userId}", s.handlePresignAvatar)
|
||||
mux.HandleFunc("PUT /api/avatar-upload/{userId}", s.handleAvatarUpload)
|
||||
|
||||
// EPUB export
|
||||
mux.HandleFunc("GET /api/export/{slug}", s.handleExportEPUB)
|
||||
|
||||
// Reading progress
|
||||
mux.HandleFunc("GET /api/progress", s.handleGetProgress)
|
||||
mux.HandleFunc("POST /api/progress/{slug}", s.handleSetProgress)
|
||||
|
||||
Reference in New Issue
Block a user