diff --git a/backend/internal/backend/epub.go b/backend/internal/backend/epub.go
new file mode 100644
index 0000000..cca5cf4
--- /dev/null
+++ b/backend/internal/backend/epub.go
@@ -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 `
+
" + mdText + "
") + } + + 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 diff --git a/backend/internal/backend/server.go b/backend/internal/backend/server.go index 696f79d..1973885 100644 --- a/backend/internal/backend/server.go +++ b/backend/internal/backend/server.go @@ -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) diff --git a/scripts/pb-init-v3.sh b/scripts/pb-init-v3.sh index 5331097..81d0b46 100755 --- a/scripts/pb-init-v3.sh +++ b/scripts/pb-init-v3.sh @@ -267,6 +267,14 @@ create "discovery_votes" '{ {"name":"action", "type":"text","required":true} ]}' +create "book_ratings" '{ + "name":"book_ratings","type":"base","fields":[ + {"name":"session_id","type":"text", "required":true}, + {"name":"user_id", "type":"text"}, + {"name":"slug", "type":"text", "required":true}, + {"name":"rating", "type":"number", "required":true} + ]}' + # ── 5. Field migrations (idempotent — adds fields missing from older installs) ─ add_field "scraping_tasks" "heartbeat_at" "date" add_field "audio_jobs" "heartbeat_at" "date" @@ -282,5 +290,6 @@ add_field "app_users" "oauth_provider" "text" add_field "app_users" "oauth_id" "text" add_field "app_users" "polar_customer_id" "text" add_field "app_users" "polar_subscription_id" "text" +add_field "user_library" "shelf" "text" log "done" diff --git a/ui/src/lib/audio.svelte.ts b/ui/src/lib/audio.svelte.ts index de4f9a0..38fbd96 100644 --- a/ui/src/lib/audio.svelte.ts +++ b/ui/src/lib/audio.svelte.ts @@ -75,6 +75,10 @@ class AudioStore { */ seekRequest = $stateDownload
+All {chapterList.length} chapters as EPUB
+