- 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.
166 lines
4.8 KiB
Go
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")
|
|
} |