Files
libnovel/backend/internal/storage/import.go
root a371acb28e feat: add PDF/EPUB import functionality
- Add ImportTask/ImportResult types to domain.go
- Add TypeImportBook to asynqqueue for task routing
- Add CreateImportTask to producer and storage layers
- Add ClaimNextImportTask/FinishImportTask to Consumer
- Add import task handling to runner (polling + Asynq handler)
- Add BookImporter interface to bookstore for PDF/EPUB parsing
- Add backend API endpoints: POST/GET /api/admin/import
- Add SvelteKit UI at /admin/import with task list
- Add nav link in admin layout

Note: PDF/EPUB parsing is a placeholder - needs external library integration.
2026-04-09 09:53:58 +05:00

166 lines
4.8 KiB
Go

package storage
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"regexp"
"strings"
"github.com/libnovel/backend/internal/bookstore"
"github.com/minio/minio-go/v7"
)
var (
chapterPattern = regexp.MustCompile(`(?i)chapter\s+(\d+)|The\s+Eminence\s+in\s+Shadow\s+(\d+)\s*-\s*(\d+)`)
)
type importer struct {
mc *minio.Client
}
// NewBookImporter creates a BookImporter that reads files from MinIO.
func NewBookImporter(mc *minio.Client) bookstore.BookImporter {
return &importer{mc: mc}
}
func (i *importer) Import(ctx context.Context, objectKey, fileType string) ([]bookstore.Chapter, error) {
if fileType != "pdf" && fileType != "epub" {
return nil, fmt.Errorf("unsupported file type: %s", fileType)
}
obj, err := i.mc.GetObject(ctx, "imports", objectKey, minio.GetObjectOptions{})
if err != nil {
return nil, fmt.Errorf("get object from minio: %w", err)
}
defer obj.Close()
data, err := io.ReadAll(obj)
if err != nil {
return nil, fmt.Errorf("read object: %w", err)
}
if fileType == "pdf" {
return i.parsePDF(data)
}
return i.parseEPUB(data)
}
func (i *importer) parsePDF(data []byte) ([]bookstore.Chapter, error) {
return nil, errors.New("PDF parsing not yet implemented - requires external library")
}
func (i *importer) parseEPUB(data []byte) ([]bookstore.Chapter, error) {
return nil, errors.New("EPUB parsing not yet implemented - requires external library")
}
// extractChaptersFromText is a helper that splits raw text into chapters.
// Used as a fallback when the PDF parser library returns raw text.
func extractChaptersFromText(text string) []bookstore.Chapter {
var chapters []bookstore.Chapter
var currentChapter *bookstore.Chapter
lines := strings.Split(text, "\n")
chapterNum := 0
for _, line := range lines {
line = strings.TrimSpace(line)
if len(line) < 3 {
continue
}
matches := chapterPattern.FindStringSubmatch(line)
if matches != nil {
if currentChapter != nil && currentChapter.Content != "" {
chapters = append(chapters, *currentChapter)
}
chapterNum++
if matches[1] != "" {
chapterNum, _ = fmt.Sscanf(matches[1], "%d", &chapterNum)
}
currentChapter = &bookstore.Chapter{
Number: chapterNum,
Title: line,
Content: "",
}
continue
}
if currentChapter != nil {
if currentChapter.Content != "" {
currentChapter.Content += " "
}
currentChapter.Content += line
}
}
if currentChapter != nil && currentChapter.Content != "" {
chapters = append(chapters, *currentChapter)
}
// If no chapters found via regex, try splitting by double newlines
if len(chapters) == 0 {
paragraphs := strings.Split(text, "\n\n")
for i, para := range paragraphs {
para = strings.TrimSpace(para)
if len(para) > 50 {
chapters = append(chapters, bookstore.Chapter{
Number: i + 1,
Title: fmt.Sprintf("Chapter %d", i+1),
Content: para,
})
}
}
}
return chapters
}
// IngestChapters stores extracted chapters for a book via BookWriter.
// This is called by the runner after extracting chapters from PDF/EPUB.
func (s *Store) IngestChapters(ctx context.Context, slug string, chapters []bookstore.Chapter) error {
// For now, store each chapter as plain text in MinIO (similar to scraped chapters)
// The BookWriter interface expects markdown, so we'll store the content as-is
for _, ch := range chapters {
content := fmt.Sprintf("# Chapter %d\n\n%s", ch.Number, ch.Content)
if ch.Title != "" {
content = fmt.Sprintf("# %s\n\n%s", ch.Title, ch.Content)
}
key := fmt.Sprintf("books/%s/chapters/%d.md", slug, ch.Number)
if err := s.mc.PutObject(ctx, "books", key, strings.NewReader(content), int64(len(content)), minio.PutObjectOptions{
ContentType: "text/markdown",
}); err != nil {
return fmt.Errorf("put chapter %d: %w", ch.Number, err)
}
}
// Also create a simple metadata entry in the books collection
// (in a real implementation, we'd update the existing book or create a placeholder)
return nil
}
// GetImportObjectKey returns the MinIO object key for an uploaded import file.
func GetImportObjectKey(filename string) string {
return fmt.Sprintf("imports/%s", filename)
}
func parsePDFWithPython(data []byte) ([]bookstore.Chapter, error) {
// This would require calling an external Python script or service
// For now, return placeholder - in production, this would integrate with
// the Python pypdf library via subprocess or API call
return nil, errors.New("PDF parsing requires Python integration")
}
// Debug helper - decode a base64-encoded PDF from bytes and extract text
func extractTextFromPDFBytes(data []byte) (string, error) {
// This is a placeholder - in production we'd use a proper Go PDF library
// like github.com/ledongthuc/pdf or the Python approach
var buf bytes.Buffer
_, err := buf.Write(data)
if err != nil {
return "", err
}
return "", errors.New("PDF text extraction not implemented in Go")
}