- parsePDF: return all text as single 'Full Text' chapter (admin splits manually) - parseEPUB: fix chapter numbering to use sequential counter not spine index - Remove dead code: chaptersFromBookmarks, cleanChapterText, extractChaptersFromText, chapterHeadingRE; drop pdfcpu alias and regexp imports - Backend: POST /api/admin/books/:slug/split-chapters endpoint — splits text on '---' dividers, optional '## Title' headers, writes chapters via WriteChapter - UI: admin panel now shows for all admin users regardless of source_url; chapter split tool shown when book has single 'Full Text' chapter, pre-fills from MinIO content
142 lines
3.8 KiB
Go
142 lines
3.8 KiB
Go
package backend
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/libnovel/backend/internal/bookstore"
|
|
"github.com/libnovel/backend/internal/domain"
|
|
)
|
|
|
|
// handleAdminSplitChapters handles POST /api/admin/books/{slug}/split-chapters.
|
|
//
|
|
// Request body (JSON):
|
|
//
|
|
// { "text": "<full text with --- dividers and optional ## Title lines>" }
|
|
//
|
|
// The text is split on lines containing only "---". Each segment may start with
|
|
// a "## Title" line which becomes the chapter title; remaining lines are the
|
|
// chapter content. Sequential chapter numbers 1..N are assigned.
|
|
//
|
|
// All existing chapters for the book are replaced: WriteChapter is called for
|
|
// each new chapter (upsert by number), so chapters beyond N are not deleted —
|
|
// use the dedup endpoint afterwards if needed.
|
|
func (s *Server) handleAdminSplitChapters(w http.ResponseWriter, r *http.Request) {
|
|
if s.deps.BookWriter == nil {
|
|
jsonError(w, http.StatusServiceUnavailable, "book writer not configured")
|
|
return
|
|
}
|
|
|
|
slug := r.PathValue("slug")
|
|
if slug == "" {
|
|
jsonError(w, http.StatusBadRequest, "slug is required")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Text string `json:"text"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.Text) == "" {
|
|
jsonError(w, http.StatusBadRequest, "text is required")
|
|
return
|
|
}
|
|
|
|
chapters := splitChapterText(req.Text)
|
|
if len(chapters) == 0 {
|
|
jsonError(w, http.StatusUnprocessableEntity, "no chapters produced from text")
|
|
return
|
|
}
|
|
|
|
for _, ch := range chapters {
|
|
var mdContent string
|
|
if ch.Title != "" && ch.Title != fmt.Sprintf("Chapter %d", ch.Number) {
|
|
mdContent = fmt.Sprintf("# %s\n\n%s", ch.Title, ch.Content)
|
|
} else {
|
|
mdContent = fmt.Sprintf("# Chapter %d\n\n%s", ch.Number, ch.Content)
|
|
}
|
|
domainCh := domain.Chapter{
|
|
Ref: domain.ChapterRef{Number: ch.Number, Title: ch.Title},
|
|
Text: mdContent,
|
|
}
|
|
if err := s.deps.BookWriter.WriteChapter(r.Context(), slug, domainCh); err != nil {
|
|
jsonError(w, http.StatusInternalServerError, fmt.Sprintf("write chapter %d: %s", ch.Number, err.Error()))
|
|
return
|
|
}
|
|
}
|
|
|
|
writeJSON(w, 0, map[string]any{
|
|
"chapters": len(chapters),
|
|
"slug": slug,
|
|
})
|
|
}
|
|
|
|
// splitChapterText splits text on "---" divider lines into bookstore.Chapter
|
|
// slices. Each segment may optionally start with a "## Title" header line.
|
|
func splitChapterText(text string) []bookstore.Chapter {
|
|
lines := strings.Split(text, "\n")
|
|
|
|
// Collect raw segments split on "---" dividers.
|
|
var segments [][]string
|
|
cur := []string{}
|
|
for _, line := range lines {
|
|
if strings.TrimSpace(line) == "---" {
|
|
segments = append(segments, cur)
|
|
cur = []string{}
|
|
} else {
|
|
cur = append(cur, line)
|
|
}
|
|
}
|
|
segments = append(segments, cur) // last segment
|
|
|
|
var chapters []bookstore.Chapter
|
|
chNum := 0
|
|
for _, seg := range segments {
|
|
// Trim leading/trailing blank lines from the segment.
|
|
start, end := 0, len(seg)
|
|
for start < end && strings.TrimSpace(seg[start]) == "" {
|
|
start++
|
|
}
|
|
for end > start && strings.TrimSpace(seg[end-1]) == "" {
|
|
end--
|
|
}
|
|
seg = seg[start:end]
|
|
if len(seg) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Check for a "## Title" header on the first line.
|
|
title := ""
|
|
contentStart := 0
|
|
if strings.HasPrefix(strings.TrimSpace(seg[0]), "## ") {
|
|
title = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(seg[0]), "## "))
|
|
contentStart = 1
|
|
// Skip blank lines after the title.
|
|
for contentStart < len(seg) && strings.TrimSpace(seg[contentStart]) == "" {
|
|
contentStart++
|
|
}
|
|
}
|
|
|
|
content := strings.TrimSpace(strings.Join(seg[contentStart:], "\n"))
|
|
if content == "" {
|
|
continue
|
|
}
|
|
|
|
chNum++
|
|
if title == "" {
|
|
title = fmt.Sprintf("Chapter %d", chNum)
|
|
}
|
|
chapters = append(chapters, bookstore.Chapter{
|
|
Number: chNum,
|
|
Title: title,
|
|
Content: content,
|
|
})
|
|
}
|
|
return chapters
|
|
}
|