Compare commits

...

6 Commits

Author SHA1 Message Date
root
899c504d1f feat(import): move PDF parsing to backend; fix heartbeat/reap for import_tasks
All checks were successful
Release / Test backend (push) Successful in 51s
Release / Check ui (push) Successful in 2m2s
Release / Docker (push) Successful in 7m32s
Release / Gitea Release (push) Successful in 1m0s
- parsePDF function restored in import.go (body was orphaned outside function)
- ParseImportFile() called at upload time with 3-min timeout; chapters stored as JSON in MinIO
- runner.go: prefer ChaptersKey path (read pre-parsed JSON) over BookImport.Import()
- ImportChapterStore interface added; store wired in runner/main.go
- HeartbeatTask and ReapStaleTasks now include import_tasks collection
- parseImportTask now returns ChaptersKey in domain.ImportTask
- asynq_runner.go handleImportTask passes ChaptersKey
- pb-init-v3.sh: chapters_key field added to import_tasks schema
2026-04-09 21:19:43 +05:00
root
d82aa9d4b4 fix(import): decrypt owner-encrypted PDFs with pdfcpu; add imports bucket to minio-init
All checks were successful
Release / Test backend (push) Successful in 2m10s
Release / Check ui (push) Successful in 1m55s
Release / Docker (push) Successful in 7m17s
Release / Gitea Release (push) Successful in 48s
- parsePDF now attempts to strip encryption via pdfcpu (empty user password)
  before handing bytes to dslipak/pdf — fixes '256-bit encryption key' error
  on publisher PDFs that use owner-only encryption (copy/print restrictions)
- Add pdfcpu v0.11.1 as direct dependency (was already indirect)
- docker-compose.yml minio-init: add 'imports' and 'translations' buckets
  so a fresh deploy creates all required buckets
2026-04-09 20:08:12 +05:00
root
ae08382b81 fix(import): wire ImportFileStore to bypass Asynq type assertion; add pb-init collections
All checks were successful
Release / Test backend (push) Successful in 44s
Release / Check ui (push) Successful in 1m56s
Release / Docker (push) Successful in 6m10s
Release / Gitea Release (push) Successful in 37s
- Add ImportFileStore interface to bookstore package
- Add ImportFileStore field to backend.Dependencies
- Wire ImportFileStore: store in cmd/backend/main.go
- handlers_import.go: use s.deps.ImportFileStore.PutImportFile instead of
  broken s.deps.Producer.(*storage.Store) type assertion (fails when Asynq active)
- pb-init-v3.sh: add import_tasks and notifications collection definitions
2026-04-09 19:05:11 +05:00
root
b9f8008c2c chore: embed git credentials in remote URL; update AGENTS.md 2026-04-09 17:09:53 +05:00
root
d25cee3d8c fix(ci): track generated admin_nav_notifications.js to avoid CDN-dependent paraglide failure
All checks were successful
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 1m54s
Release / Docker (push) Successful in 5m45s
Release / Gitea Release (push) Successful in 35s
2026-04-09 17:03:30 +05:00
root
48714cd98b fix(import): persist object_key + metadata; add nav + logout session cleanup
Some checks failed
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Failing after 29s
Release / Docker (push) Has been skipped
Release / Gitea Release (push) Has been skipped
- Import task: persist object_key, author, cover_url, genres, summary,
  book_status in PocketBase so the runner can fetch the file and write
  book metadata on completion
- Runner poll mode: pass task.ObjectKey instead of empty string
- Runner: write BookMeta + UpsertBook in Meilisearch after chapter ingest
  so imported books appear in catalogue and search
- Import UI: add author, cover URL, genres, summary, status fields; add
  AI tasks panel (chapter names, description, image gen, tagline) after
  import completes; add AI tasks button on each done task in the list
- Admin nav: add Notifications entry to sidebar (all 5 locales)
- Logout: delete user_sessions row on sign-out so sessions don't
  accumulate as phantoms after each login/logout cycle
2026-04-09 16:59:40 +05:00
29 changed files with 761 additions and 193 deletions

View File

@@ -47,11 +47,21 @@ Sub-directories have their own `AGENTS.md` with deeper context (e.g. `ios/AGENTS
- `release.yaml` — runs on `v*` tags (build Docker images, upload source maps, create Gitea release)
- Secrets: `DOCKER_USER`, `DOCKER_TOKEN`, `GITEA_TOKEN`, `GLITCHTIP_AUTH_TOKEN`
### Git credentials
Credentials are embedded in the remote URL — no `HOME=/root` or credential helper needed for push:
```
https://kamil:95782641Apple%24@gitea.kalekber.cc/kamil/libnovel.git
```
All git commands still use `HOME=/root` prefix for consistency (picks up `/root/.gitconfig` for user name/email), but push auth works without it.
### Releasing a new version
```bash
git tag v2.5.X -m "Short title\n\nOptional longer body"
git push origin v2.5.X
HOME=/root git tag v2.6.X -m "Short title"
HOME=/root git push origin v3-cleanup --tags
```
CI will build all Docker images, upload source maps to GlitchTip, and create a Gitea release automatically.

View File

@@ -189,6 +189,7 @@ func run() error {
ChapterImageStore: store,
Producer: producer,
TaskReader: store,
ImportFileStore: store,
SearchIndex: searchIndex,
Kokoro: kokoroClient,
PocketTTS: pocketTTSClient,

View File

@@ -192,21 +192,22 @@ func run() error {
deps := runner.Dependencies{
Consumer: consumer,
BookWriter: store,
BookReader: store,
AudioStore: store,
CoverStore: store,
TranslationStore: store,
BookImport: storage.NewBookImporter(store),
ChapterIngester: store,
SearchIndex: searchIndex,
Novel: novel,
Kokoro: kokoroClient,
PocketTTS: pocketTTSClient,
CFAI: cfaiClient,
LibreTranslate: ltClient,
Notifier: store,
Log: log,
BookWriter: store,
BookReader: store,
AudioStore: store,
CoverStore: store,
TranslationStore: store,
BookImport: storage.NewBookImporter(store),
ImportChapterStore: store,
ChapterIngester: store,
SearchIndex: searchIndex,
Novel: novel,
Kokoro: kokoroClient,
PocketTTS: pocketTTSClient,
CFAI: cfaiClient,
LibreTranslate: ltClient,
Notifier: store,
Log: log,
}
r := runner.New(rCfg, deps)

View File

@@ -38,6 +38,7 @@ require (
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pdfcpu/pdfcpu v0.11.1 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect

View File

@@ -7,6 +7,7 @@ import (
"log/slog"
"github.com/hibiken/asynq"
"github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/taskqueue"
)
@@ -88,18 +89,19 @@ func (p *Producer) CreateTranslationTask(ctx context.Context, slug string, chapt
}
// CreateImportTask creates a PocketBase record then enqueues an Asynq job for PDF/EPUB import.
func (p *Producer) CreateImportTask(ctx context.Context, slug, title, fileType, objectKey, initiatorUserID string) (string, error) {
id, err := p.pb.CreateImportTask(ctx, slug, title, fileType, objectKey, initiatorUserID)
func (p *Producer) CreateImportTask(ctx context.Context, task domain.ImportTask) (string, error) {
id, err := p.pb.CreateImportTask(ctx, task)
if err != nil {
return "", err
}
payload := ImportPayload{
PBTaskID: id,
Slug: slug,
Title: title,
FileType: fileType,
ObjectKey: objectKey,
PBTaskID: id,
Slug: task.Slug,
Title: task.Title,
FileType: task.FileType,
ObjectKey: task.ObjectKey,
ChaptersKey: task.ChaptersKey,
}
if err := p.enqueue(ctx, TypeImportBook, payload); err != nil {
// Non-fatal: PB record exists; runner will pick it up on next poll.

View File

@@ -48,9 +48,10 @@ type ScrapePayload struct {
// ImportPayload is the Asynq job payload for PDF/EPUB import tasks.
type ImportPayload struct {
PBTaskID string `json:"pb_task_id"`
Slug string `json:"slug"`
Title string `json:"title"`
FileType string `json:"file_type"` // "pdf" or "epub"
ObjectKey string `json:"object_key"` // MinIO path to uploaded file
PBTaskID string `json:"pb_task_id"`
Slug string `json:"slug"`
Title string `json:"title"`
FileType string `json:"file_type"` // "pdf" or "epub"
ObjectKey string `json:"object_key"` // MinIO path to uploaded file
ChaptersKey string `json:"chapters_key"` // MinIO path to pre-parsed chapters JSON
}

View File

@@ -1,6 +1,7 @@
package backend
import (
"context"
"encoding/json"
"fmt"
"io"
@@ -9,14 +10,20 @@ import (
"strings"
"time"
"github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/storage"
)
type importRequest struct {
Title string `json:"title"`
FileName string `json:"file_name"`
FileType string `json:"file_type"` // "pdf" or "epub"
ObjectKey string `json:"object_key"` // MinIO path to uploaded file
Title string `json:"title"`
Author string `json:"author"`
CoverURL string `json:"cover_url"`
Genres []string `json:"genres"`
Summary string `json:"summary"`
BookStatus string `json:"book_status"` // "ongoing" | "completed" | "hiatus"
FileName string `json:"file_name"`
FileType string `json:"file_type"` // "pdf" or "epub"
ObjectKey string `json:"object_key"` // MinIO path to uploaded file
}
type importResponse struct {
@@ -39,6 +46,8 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
ct := r.Header.Get("Content-Type")
var req importRequest
var objectKey string
var chaptersKey string
var chapterCount int
if strings.HasPrefix(ct, "multipart/form-data") {
if err := r.ParseMultipartForm(32 << 20); err != nil {
@@ -46,6 +55,17 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
return
}
req.Title = r.FormValue("title")
req.Author = r.FormValue("author")
req.CoverURL = r.FormValue("cover_url")
req.Summary = r.FormValue("summary")
req.BookStatus = r.FormValue("book_status")
if g := r.FormValue("genres"); g != "" {
for _, s := range strings.Split(g, ",") {
if s = strings.TrimSpace(s); s != "" {
req.Genres = append(req.Genres, s)
}
}
}
req.FileName = r.FormValue("file_name")
req.FileType = r.FormValue("file_type")
analyzeOnly := r.FormValue("analyze") == "true"
@@ -79,17 +99,38 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
return
}
// Upload to MinIO for actual import
// Parse PDF/EPUB on the backend (with timeout) and store chapters as JSON.
// The runner only needs to ingest pre-parsed chapters — no PDF parsing on runner.
parseCtx, parseCancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer parseCancel()
chapters, parseErr := storage.ParseImportFile(parseCtx, data, req.FileType)
if parseErr != nil || len(chapters) == 0 {
jsonError(w, http.StatusUnprocessableEntity, "could not parse file: "+func() string {
if parseErr != nil { return parseErr.Error() }
return "no chapters found"
}())
return
}
// Store raw file in MinIO (for reference/re-import).
objectKey = fmt.Sprintf("imports/%d_%s", time.Now().Unix(), header.Filename)
store, ok := s.deps.Producer.(*storage.Store)
if !ok {
if s.deps.ImportFileStore == nil {
jsonError(w, http.StatusInternalServerError, "storage not available")
return
}
if err := store.PutImportFile(r.Context(), objectKey, data); err != nil {
if err := s.deps.ImportFileStore.PutImportFile(r.Context(), objectKey, data); err != nil {
jsonError(w, http.StatusInternalServerError, "upload file: "+err.Error())
return
}
// Store pre-parsed chapters JSON in MinIO so runner can ingest without re-parsing.
chaptersJSON, _ := json.Marshal(chapters)
chaptersKey = fmt.Sprintf("imports/%d_%s_chapters.json", time.Now().Unix(), strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename)))
if err := s.deps.ImportFileStore.PutImportChapters(r.Context(), chaptersKey, chaptersJSON); err != nil {
jsonError(w, http.StatusInternalServerError, "store chapters: "+err.Error())
return
}
chapterCount = len(chapters)
} else {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
@@ -115,15 +156,29 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
return -1
}, slug)
taskID, err := s.deps.Producer.CreateImportTask(r.Context(), slug, req.Title, req.FileType, objectKey, "")
taskID, err := s.deps.Producer.CreateImportTask(r.Context(), domain.ImportTask{
Slug: slug,
Title: req.Title,
Author: req.Author,
CoverURL: req.CoverURL,
Genres: req.Genres,
Summary: req.Summary,
BookStatus: req.BookStatus,
FileType: req.FileType,
ObjectKey: objectKey,
ChaptersKey: chaptersKey,
ChaptersTotal: chapterCount,
InitiatorUserID: "",
})
if err != nil {
jsonError(w, http.StatusInternalServerError, "create import task: "+err.Error())
return
}
writeJSON(w, 0, importResponse{
TaskID: taskID,
Slug: slug,
TaskID: taskID,
Slug: slug,
Preview: &importPreview{Chapters: chapterCount},
})
}

View File

@@ -85,6 +85,9 @@ type Dependencies struct {
// BookWriter writes book metadata and chapter refs to PocketBase.
// Used by admin text-gen apply endpoints.
BookWriter bookstore.BookWriter
// ImportFileStore uploads raw PDF/EPUB files to MinIO for the runner to process.
// Always wired to the concrete *storage.Store (not the Asynq wrapper).
ImportFileStore bookstore.ImportFileStore
// AIJobStore tracks long-running AI generation jobs in PocketBase.
// If nil, job persistence is disabled (jobs still run but are not recorded).
AIJobStore bookstore.AIJobStore

View File

@@ -215,3 +215,14 @@ type BookImporter interface {
// Returns the extracted chapters or an error.
Import(ctx context.Context, objectKey, fileType string) ([]Chapter, error)
}
// ImportFileStore uploads raw import files to object storage.
// Kept separate from BookImporter so the HTTP handler can upload the file
// without a concrete type assertion, regardless of which Producer is wired.
type ImportFileStore interface {
PutImportFile(ctx context.Context, objectKey string, data []byte) error
// PutImportChapters stores the pre-parsed chapters JSON under the given key.
PutImportChapters(ctx context.Context, key string, data []byte) error
// GetImportChapters retrieves the pre-parsed chapters JSON.
GetImportChapters(ctx context.Context, key string) ([]byte, error)
}

View File

@@ -177,6 +177,13 @@ type ImportTask struct {
Title string `json:"title"`
FileName string `json:"file_name"`
FileType string `json:"file_type"` // "pdf" or "epub"
ObjectKey string `json:"object_key,omitempty"` // MinIO path to uploaded file
ChaptersKey string `json:"chapters_key,omitempty"` // MinIO path to pre-parsed chapters JSON
Author string `json:"author,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
Genres []string `json:"genres,omitempty"`
Summary string `json:"summary,omitempty"`
BookStatus string `json:"book_status,omitempty"` // "ongoing" | "completed" | "hiatus"
WorkerID string `json:"worker_id,omitempty"`
InitiatorUserID string `json:"initiator_user_id,omitempty"` // PocketBase user ID who submitted the import
Status TaskStatus `json:"status"`

View File

@@ -199,10 +199,11 @@ func (r *Runner) handleImportTask(ctx context.Context, t *asynq.Task) error {
return fmt.Errorf("unmarshal import payload: %w", err)
}
task := domain.ImportTask{
ID: p.PBTaskID,
Slug: p.Slug,
Title: p.Title,
FileType: p.FileType,
ID: p.PBTaskID,
Slug: p.Slug,
Title: p.Title,
FileType: p.FileType,
ChaptersKey: p.ChaptersKey,
}
r.tasksRunning.Add(1)
defer r.tasksRunning.Add(-1)

View File

@@ -15,6 +15,7 @@ package runner
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
@@ -49,6 +50,11 @@ type ChapterIngester interface {
IngestChapters(ctx context.Context, slug string, chapters []bookstore.Chapter) error
}
// ImportChapterStore retrieves pre-parsed chapter JSON blobs from object storage.
type ImportChapterStore interface {
GetImportChapters(ctx context.Context, key string) ([]byte, error)
}
// Config tunes the runner behaviour.
type Config struct {
// WorkerID uniquely identifies this runner instance in PocketBase records.
@@ -114,7 +120,12 @@ type Dependencies struct {
// CoverStore stores book cover images in MinIO.
CoverStore bookstore.CoverStore
// BookImport handles PDF/EPUB file parsing and chapter extraction.
// Kept for backward compatibility when ChaptersKey is not set.
BookImport bookstore.BookImporter
// ImportChapterStore retrieves pre-parsed chapter JSON blobs from MinIO.
// When set and the task has a ChaptersKey, the runner reads from here
// instead of calling BookImport.Import() (the new preferred path).
ImportChapterStore ImportChapterStore
// ChapterIngester persists extracted chapters into MinIO/PocketBase.
ChapterIngester ChapterIngester
// Notifier creates notifications for users.
@@ -432,9 +443,7 @@ importLoop:
defer wg.Done()
defer func() { <-importSem }()
defer r.tasksRunning.Add(-1)
// Import tasks need object key - we'll need to fetch it from the task record
// For now, assume it's stored in a field or we need to add it
r.runImportTask(ctx, t, "")
r.runImportTask(ctx, t, t.ObjectKey)
}(task)
}
}
@@ -677,6 +686,10 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
}
// runImportTask executes one PDF/EPUB import task.
// Preferred path: when task.ChaptersKey is set, it reads pre-parsed chapters
// JSON from MinIO (written by the backend at upload time) and ingests them.
// Fallback path: when ChaptersKey is empty, calls BookImport.Import() to
// parse the raw file on the runner (legacy behaviour, not used for new tasks).
func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, objectKey string) {
ctx, span := otel.Tracer("runner").Start(ctx, "runner.import_task")
defer span.End()
@@ -684,10 +697,11 @@ func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, obje
attribute.String("task.id", task.ID),
attribute.String("book.slug", task.Slug),
attribute.String("file.type", task.FileType),
attribute.String("chapters_key", task.ChaptersKey),
)
log := r.deps.Log.With("task_id", task.ID, "slug", task.Slug, "file_type", task.FileType)
log.Info("runner: import task starting")
log.Info("runner: import task starting", "chapters_key", task.ChaptersKey)
hbCtx, hbCancel := context.WithCancel(ctx)
defer hbCancel()
@@ -716,15 +730,33 @@ func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, obje
}
}
if r.deps.BookImport == nil {
fail("book import not configured (BookImport dependency missing)")
return
}
var chapters []bookstore.Chapter
chapters, err := r.deps.BookImport.Import(ctx, objectKey, task.FileType)
if err != nil {
fail(fmt.Sprintf("import file: %v", err))
return
if task.ChaptersKey != "" && r.deps.ImportChapterStore != nil {
// New path: read pre-parsed chapters JSON uploaded by the backend.
raw, err := r.deps.ImportChapterStore.GetImportChapters(ctx, task.ChaptersKey)
if err != nil {
fail(fmt.Sprintf("get chapters JSON: %v", err))
return
}
if err := json.Unmarshal(raw, &chapters); err != nil {
fail(fmt.Sprintf("unmarshal chapters JSON: %v", err))
return
}
log.Info("runner: loaded pre-parsed chapters", "count", len(chapters))
} else {
// Legacy path: parse the raw file on the runner.
if r.deps.BookImport == nil {
fail("book import not configured (BookImport dependency missing)")
return
}
var err error
chapters, err = r.deps.BookImport.Import(ctx, objectKey, task.FileType)
if err != nil {
fail(fmt.Sprintf("import file: %v", err))
return
}
log.Info("runner: parsed chapters from file (legacy path)", "count", len(chapters))
}
if len(chapters) == 0 {
@@ -732,27 +764,41 @@ func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, obje
return
}
// Store chapters via BookWriter
// Note: BookWriter.WriteChapters expects domain.Chapter, need conversion
var domainChapters []bookstore.Chapter
for _, ch := range chapters {
domainChapters = append(domainChapters, bookstore.Chapter{
Number: ch.Number,
Title: ch.Title,
Content: ch.Content,
})
}
// Store chapters via ChapterIngester
// Persist chapters via ChapterIngester.
if r.deps.ChapterIngester == nil {
fail("chapter ingester not configured")
return
}
if err := r.deps.ChapterIngester.IngestChapters(ctx, task.Slug, domainChapters); err != nil {
if err := r.deps.ChapterIngester.IngestChapters(ctx, task.Slug, chapters); err != nil {
fail(fmt.Sprintf("store chapters: %v", err))
return
}
// Write book metadata so the book appears in PocketBase catalogue.
if r.deps.BookWriter != nil {
meta := domain.BookMeta{
Slug: task.Slug,
Title: task.Title,
Author: task.Author,
Cover: task.CoverURL,
Status: task.BookStatus,
Genres: task.Genres,
Summary: task.Summary,
TotalChapters: len(chapters),
}
if meta.Status == "" {
meta.Status = "completed"
}
if err := r.deps.BookWriter.WriteMetadata(ctx, meta); err != nil {
log.Warn("runner: import task WriteMetadata failed (non-fatal)", "err", err)
} else {
// Index in Meilisearch so the book is searchable.
if err := r.deps.SearchIndex.UpsertBook(ctx, meta); err != nil {
log.Warn("runner: import task meilisearch upsert failed (non-fatal)", "err", err)
}
}
}
r.tasksCompleted.Add(1)
span.SetStatus(codes.Ok, "")
result := domain.ImportResult{
@@ -763,7 +809,7 @@ func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, obje
log.Error("runner: FinishImportTask failed", "err", err)
}
// Create notification for the user who initiated the import
// Notify the user who initiated the import.
if r.deps.Notifier != nil {
msg := fmt.Sprintf("Import completed: %d chapters from %s", len(chapters), task.Title)
targetUser := task.InitiatorUserID

View File

@@ -15,6 +15,8 @@ import (
"github.com/libnovel/backend/internal/bookstore"
"github.com/libnovel/backend/internal/domain"
minio "github.com/minio/minio-go/v7"
"github.com/pdfcpu/pdfcpu/pkg/api"
"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model"
"golang.org/x/net/html"
)
@@ -90,7 +92,67 @@ func AnalyzeFile(data []byte, fileType string) (chapterCount int, firstLines []s
// decryptPDF strips encryption from a PDF using an empty user password.
// Returns the decrypted bytes, or an error if decryption is not possible.
// This handles the common case of "owner-only" encrypted PDFs (copy/print
// restrictions) which use an empty user password and open normally in readers.
func decryptPDF(data []byte) ([]byte, error) {
conf := model.NewDefaultConfiguration()
conf.UserPW = ""
conf.OwnerPW = ""
var out bytes.Buffer
err := api.Decrypt(bytes.NewReader(data), &out, conf)
if err != nil {
return nil, err
}
return out.Bytes(), nil
}
// ParseImportFile parses a PDF or EPUB and returns chapters.
// Unlike AnalyzeFile it respects ctx cancellation so callers can apply a timeout.
// For PDFs it first attempts to strip encryption with an empty password.
func ParseImportFile(ctx context.Context, data []byte, fileType string) ([]bookstore.Chapter, error) {
type result struct {
chapters []bookstore.Chapter
err error
}
ch := make(chan result, 1)
go func() {
var chapters []bookstore.Chapter
var err error
switch fileType {
case "pdf":
chapters, err = parsePDF(data)
case "epub":
chapters, err = parseEPUB(data)
default:
err = fmt.Errorf("unsupported file type: %s", fileType)
}
ch <- result{chapters, err}
}()
select {
case <-ctx.Done():
return nil, fmt.Errorf("parse timed out: %w", ctx.Err())
case r := <-ch:
return r.chapters, r.err
}
}
// parsePDF extracts chapters from PDF bytes using dslipak/pdf.
// It first attempts to decrypt the PDF with an empty password in case the file
// uses owner-only encryption (copy/print restrictions), which is common for
// publisher PDFs that open normally in readers but confuse raw parsers.
func parsePDF(data []byte) ([]bookstore.Chapter, error) {
// If the PDF is encrypted, try to decrypt it with an empty password.
// Many publisher PDFs use owner-only encryption (copy/print restrictions)
// with an empty user password, so they open normally but confuse parsers.
decrypted, err := decryptPDF(data)
if err == nil {
data = decrypted
}
// (if decryption fails we still attempt to parse — maybe it works anyway)
r, err := pdf.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("open PDF: %w", err)

View File

@@ -647,17 +647,26 @@ func (s *Store) CreateTranslationTask(ctx context.Context, slug string, chapter
return rec.ID, nil
}
func (s *Store) CreateImportTask(ctx context.Context, slug, title, fileType, objectKey, initiatorUserID string) (string, error) {
func (s *Store) CreateImportTask(ctx context.Context, task domain.ImportTask) (string, error) {
payload := map[string]any{
"slug": slug,
"title": title,
"file_name": slug + "." + fileType,
"file_type": fileType,
"slug": task.Slug,
"title": task.Title,
"file_name": task.Slug + "." + task.FileType,
"file_type": task.FileType,
"object_key": task.ObjectKey,
"chapters_key": task.ChaptersKey,
"author": task.Author,
"cover_url": task.CoverURL,
"summary": task.Summary,
"book_status": task.BookStatus,
"status": string(domain.TaskStatusPending),
"chapters_done": 0,
"chapters_total": 0,
"chapters_total": task.ChaptersTotal,
"started": time.Now().UTC().Format(time.RFC3339),
"initiator_user_id": initiatorUserID,
"initiator_user_id": task.InitiatorUserID,
}
if len(task.Genres) > 0 {
payload["genres"] = strings.Join(task.Genres, ",")
}
var rec struct {
ID string `json:"id"`
@@ -906,7 +915,7 @@ func (s *Store) FailTask(ctx context.Context, id, errMsg string) error {
}
// HeartbeatTask updates the heartbeat_at field on a running task.
// Tries scraping_tasks first, then audio_jobs, then translation_jobs.
// Tries scraping_tasks, audio_jobs, translation_jobs, then import_tasks.
func (s *Store) HeartbeatTask(ctx context.Context, id string) error {
payload := map[string]any{
"heartbeat_at": time.Now().UTC().Format(time.RFC3339),
@@ -917,7 +926,10 @@ func (s *Store) HeartbeatTask(ctx context.Context, id string) error {
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), payload); err == nil {
return nil
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/translation_jobs/records/%s", id), payload)
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/translation_jobs/records/%s", id), payload); err == nil {
return nil
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/import_tasks/records/%s", id), payload)
}
// ReapStaleTasks finds all running tasks whose heartbeat_at is either missing
@@ -935,7 +947,7 @@ func (s *Store) ReapStaleTasks(ctx context.Context, staleAfter time.Duration) (i
}
total := 0
for _, collection := range []string{"scraping_tasks", "audio_jobs", "translation_jobs"} {
for _, collection := range []string{"scraping_tasks", "audio_jobs", "translation_jobs", "import_tasks"} {
items, err := s.pb.listAll(ctx, collection, filter, "")
if err != nil {
return total, fmt.Errorf("ReapStaleTasks list %s: %w", collection, err)
@@ -1176,6 +1188,13 @@ func parseImportTask(raw json.RawMessage) (domain.ImportTask, error) {
Title string `json:"title"`
FileName string `json:"file_name"`
FileType string `json:"file_type"`
ObjectKey string `json:"object_key"`
ChaptersKey string `json:"chapters_key"`
Author string `json:"author"`
CoverURL string `json:"cover_url"`
Genres string `json:"genres"` // stored as comma-separated
Summary string `json:"summary"`
BookStatus string `json:"book_status"`
WorkerID string `json:"worker_id"`
InitiatorUserID string `json:"initiator_user_id"`
Status string `json:"status"`
@@ -1190,12 +1209,27 @@ func parseImportTask(raw json.RawMessage) (domain.ImportTask, error) {
}
started, _ := time.Parse(time.RFC3339, rec.Started)
finished, _ := time.Parse(time.RFC3339, rec.Finished)
var genres []string
if rec.Genres != "" {
for _, g := range strings.Split(rec.Genres, ",") {
if g = strings.TrimSpace(g); g != "" {
genres = append(genres, g)
}
}
}
return domain.ImportTask{
ID: rec.ID,
Slug: rec.Slug,
Title: rec.Title,
FileName: rec.FileName,
FileType: rec.FileType,
ObjectKey: rec.ObjectKey,
ChaptersKey: rec.ChaptersKey,
Author: rec.Author,
CoverURL: rec.CoverURL,
Genres: genres,
Summary: rec.Summary,
BookStatus: rec.BookStatus,
WorkerID: rec.WorkerID,
InitiatorUserID: rec.InitiatorUserID,
Status: domain.TaskStatus(rec.Status),
@@ -1238,6 +1272,20 @@ func (s *Store) PutImportFile(ctx context.Context, key string, data []byte) erro
return s.mc.putObject(ctx, "imports", key, "application/octet-stream", data)
}
// PutImportChapters stores a pre-parsed chapters JSON blob in MinIO.
func (s *Store) PutImportChapters(ctx context.Context, key string, data []byte) error {
return s.mc.putObject(ctx, "imports", key, "application/json", data)
}
// GetImportChapters retrieves the pre-parsed chapters JSON from MinIO.
func (s *Store) GetImportChapters(ctx context.Context, key string) ([]byte, error) {
data, err := s.mc.getObject(ctx, "imports", key)
if err != nil {
return nil, fmt.Errorf("get chapters object: %w", err)
}
return data, nil
}
func (s *Store) CoverExists(ctx context.Context, slug string) bool {
return s.mc.coverExists(ctx, CoverObjectKey(slug))
}

View File

@@ -35,8 +35,8 @@ type Producer interface {
// CreateImportTask inserts a new import task with status=pending and
// returns the assigned PocketBase record ID.
// initiatorUserID is the PocketBase user ID who submitted the import (may be empty).
CreateImportTask(ctx context.Context, slug, title, fileType, objectKey, initiatorUserID string) (string, error)
// The task struct must have at minimum Slug, Title, FileType, and ObjectKey set.
CreateImportTask(ctx context.Context, task domain.ImportTask) (string, error)
// CancelTask transitions a pending task to status=cancelled.
// Returns ErrNotFound if the task does not exist.

View File

@@ -26,7 +26,7 @@ func (s *stubStore) CreateAudioTask(_ context.Context, _ string, _ int, _ string
func (s *stubStore) CreateTranslationTask(_ context.Context, _ string, _ int, _ string) (string, error) {
return "translation-1", nil
}
func (s *stubStore) CreateImportTask(_ context.Context, _, _, _, _, _ string) (string, error) {
func (s *stubStore) CreateImportTask(_ context.Context, _ domain.ImportTask) (string, error) {
return "import-1", nil
}
func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil }

View File

@@ -58,6 +58,8 @@ services:
mc mb --ignore-existing local/audio;
mc mb --ignore-existing local/avatars;
mc mb --ignore-existing local/catalogue;
mc mb --ignore-existing local/translations;
mc mb --ignore-existing local/imports;
echo 'buckets ready';
"
environment:

View File

@@ -299,6 +299,40 @@ create "translation_jobs" '{
{"name":"heartbeat_at", "type":"date"}
]}'
create "import_tasks" '{
"name":"import_tasks","type":"base","fields":[
{"name":"slug", "type":"text", "required":true},
{"name":"title", "type":"text", "required":true},
{"name":"file_name", "type":"text"},
{"name":"file_type", "type":"text"},
{"name":"object_key", "type":"text"},
{"name":"chapters_key", "type":"text"},
{"name":"author", "type":"text"},
{"name":"cover_url", "type":"text"},
{"name":"genres", "type":"text"},
{"name":"summary", "type":"text"},
{"name":"book_status", "type":"text"},
{"name":"worker_id", "type":"text"},
{"name":"initiator_user_id", "type":"text"},
{"name":"status", "type":"text", "required":true},
{"name":"chapters_done", "type":"number"},
{"name":"chapters_total", "type":"number"},
{"name":"error_message", "type":"text"},
{"name":"started", "type":"date"},
{"name":"finished", "type":"date"},
{"name":"heartbeat_at", "type":"date"}
]}'
create "notifications" '{
"name":"notifications","type":"base","fields":[
{"name":"user_id", "type":"text","required":true},
{"name":"title", "type":"text","required":true},
{"name":"message", "type":"text"},
{"name":"link", "type":"text"},
{"name":"read", "type":"bool"},
{"name":"created", "type":"date"}
]}'
create "ai_jobs" '{
"name":"ai_jobs","type":"base","fields":[
{"name":"kind", "type":"text", "required":true},

View File

@@ -408,6 +408,7 @@
"admin_nav_text_gen": "Text Gen",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_ai_jobs": "AI Jobs",
"admin_nav_notifications": "Notifications",
"admin_nav_feedback": "Feedback",
"admin_nav_errors": "Errors",
"admin_nav_analytics": "Analytics",

View File

@@ -378,6 +378,7 @@
"admin_nav_text_gen": "Text Gen",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_ai_jobs": "Tâches IA",
"admin_nav_notifications": "Notifications",
"admin_nav_errors": "Erreurs",
"admin_nav_analytics": "Analytique",
"admin_nav_logs": "Journaux",

View File

@@ -378,6 +378,7 @@
"admin_nav_text_gen": "Text Gen",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_ai_jobs": "Tugas AI",
"admin_nav_notifications": "Notifikasi",
"admin_nav_errors": "Kesalahan",
"admin_nav_analytics": "Analitik",
"admin_nav_logs": "Log",

View File

@@ -378,6 +378,7 @@
"admin_nav_text_gen": "Text Gen",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_ai_jobs": "Tarefas de IA",
"admin_nav_notifications": "Notificações",
"admin_nav_errors": "Erros",
"admin_nav_analytics": "Análise",
"admin_nav_logs": "Logs",

View File

@@ -378,6 +378,7 @@
"admin_nav_text_gen": "Text Gen",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_ai_jobs": "Задачи ИИ",
"admin_nav_notifications": "Уведомления",
"admin_nav_errors": "Ошибки",
"admin_nav_analytics": "Аналитика",
"admin_nav_logs": "Логи",

View File

@@ -379,6 +379,7 @@ export * from './admin_nav_image_gen.js'
export * from './admin_nav_text_gen.js'
export * from './admin_nav_catalogue_tools.js'
export * from './admin_nav_ai_jobs.js'
export * from './admin_nav_notifications.js'
export * from './admin_nav_feedback.js'
export * from './admin_nav_errors.js'
export * from './admin_nav_analytics.js'

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Nav_NotificationsInputs */
const en_admin_nav_notifications = /** @type {(inputs: Admin_Nav_NotificationsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Notifications`)
};
const ru_admin_nav_notifications = /** @type {(inputs: Admin_Nav_NotificationsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Уведомления`)
};
const id_admin_nav_notifications = /** @type {(inputs: Admin_Nav_NotificationsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Notifikasi`)
};
const pt_admin_nav_notifications = /** @type {(inputs: Admin_Nav_NotificationsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Notificações`)
};
const fr_admin_nav_notifications = /** @type {(inputs: Admin_Nav_NotificationsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Notifications`)
};
/**
* | output |
* | --- |
* | "Notifications" |
*
* @param {Admin_Nav_NotificationsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_notifications = /** @type {((inputs?: Admin_Nav_NotificationsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_NotificationsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_notifications(inputs)
if (locale === "ru") return ru_admin_nav_notifications(inputs)
if (locale === "id") return id_admin_nav_notifications(inputs)
if (locale === "pt") return pt_admin_nav_notifications(inputs)
return fr_admin_nav_notifications(inputs)
});

View File

@@ -1383,6 +1383,20 @@ export async function revokeUserSession(recordId: string, userId: string): Promi
return del.ok || del.status === 204;
}
/**
* Delete a session by its auth session ID (the value stored in the cookie).
* Used on logout so the row doesn't linger as a phantom active session.
*/
export async function deleteSessionByAuthId(authSessionId: string): Promise<void> {
const row = await listOne<UserSession>('user_sessions', `session_id="${authSessionId}"`);
if (!row) return;
const token = await getToken();
await fetch(`${PB_URL}/api/collections/user_sessions/records/${row.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
}).catch(() => {});
}
/**
* Revoke all sessions for a user (used on password change etc).
*/

View File

@@ -38,6 +38,11 @@
label: () => m.admin_nav_ai_jobs(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />`
},
{
href: '/admin/notifications',
label: () => m.admin_nav_notifications(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />`
},
{
href: '/admin/catalogue-tools',
label: () => m.admin_nav_catalogue_tools(),

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
interface ImportTask {
id: string;
@@ -7,6 +8,11 @@
title: string;
file_name: string;
file_type: string;
author: string;
cover_url: string;
genres: string[];
summary: string;
book_status: string;
status: string;
chapters_done: number;
chapters_total: number;
@@ -18,17 +24,35 @@
interface PendingImport {
file: File;
title: string;
author: string;
coverUrl: string;
genres: string;
summary: string;
bookStatus: string;
preview: { chapters: number; firstLines: string[] };
}
let tasks = $state<ImportTask[]>([]);
let loading = $state(true);
let uploading = $state(false);
let title = $state('');
let error = $state('');
let selectedFile = $state<File | null>(null);
let pendingImport = $state<PendingImport | null>(null);
let analyzing = $state(false);
let error = $state('');
// Form fields
let selectedFile = $state<File | null>(null);
let title = $state('');
let author = $state('');
let coverUrl = $state('');
let genres = $state('');
let summary = $state('');
let bookStatus = $state('completed');
let pendingImport = $state<PendingImport | null>(null);
// AI panel: slug of recently completed import
let aiSlug = $state('');
let aiTitle = $state('');
let showAiPanel = $state(false);
async function loadTasks() {
loading = true;
@@ -45,7 +69,7 @@
}
}
async function handleFileSelect(e: Event) {
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (!input.files?.length) return;
const file = input.files[0];
@@ -54,8 +78,12 @@
error = 'Please select a PDF or EPUB file';
return;
}
error = '';
selectedFile = file;
title = file.name.replace(/\.(pdf|epub)$/i, '').replace(/[-_]/g, ' ');
// Auto-fill title from filename if empty
if (!title.trim()) {
title = file.name.replace(/\.(pdf|epub)$/i, '').replace(/[-_]/g, ' ');
}
}
async function analyzeFile() {
@@ -65,24 +93,26 @@
try {
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('title', title);
formData.append('title', title.trim());
formData.append('analyze', 'true');
const res = await fetch('/api/admin/import', {
method: 'POST',
body: formData
});
const res = await fetch('/api/admin/import', { method: 'POST', body: formData });
if (res.ok) {
const data = await res.json();
pendingImport = {
file: selectedFile,
title: title,
title: title.trim(),
author: author.trim(),
coverUrl: coverUrl.trim(),
genres: genres.trim(),
summary: summary.trim(),
bookStatus,
preview: data.preview || { chapters: 0, firstLines: [] }
};
} else {
const d = await res.json().catch(() => ({}));
error = d.error || 'Failed to analyze file';
}
} catch (e) {
} catch {
error = 'Failed to analyze file';
} finally {
analyzing = false;
@@ -97,20 +127,36 @@
const formData = new FormData();
formData.append('file', pendingImport.file);
formData.append('title', pendingImport.title);
const res = await fetch('/api/admin/import', {
method: 'POST',
body: formData
});
formData.append('author', pendingImport.author);
formData.append('cover_url', pendingImport.coverUrl);
formData.append('genres', pendingImport.genres);
formData.append('summary', pendingImport.summary);
formData.append('book_status', pendingImport.bookStatus);
const res = await fetch('/api/admin/import', { method: 'POST', body: formData });
if (res.ok) {
const data = await res.json();
// Save for AI panel before clearing state
const importedSlug = data.slug || '';
const importedTitle = pendingImport.title;
// Reset form
pendingImport = null;
selectedFile = null;
title = '';
author = '';
coverUrl = '';
genres = '';
summary = '';
bookStatus = 'completed';
// Show AI panel for this slug
aiSlug = importedSlug;
aiTitle = importedTitle;
showAiPanel = !!aiSlug;
await loadTasks();
} else {
const d = await res.json().catch(() => ({}));
error = d.error || 'Import failed';
}
} catch (e) {
} catch {
error = 'Import failed';
} finally {
uploading = false;
@@ -126,149 +172,308 @@
return new Date(dateStr).toLocaleString();
}
function getStatusColor(status: string) {
function statusColor(status: string) {
switch (status) {
case 'pending': return 'text-yellow-400';
case 'running': return 'text-blue-400';
case 'done': return 'text-green-400';
case 'failed': return 'text-red-400';
default: return 'text-gray-400';
default: return 'text-(--color-muted)';
}
}
onMount(() => {
loadTasks();
onMount(() => { loadTasks(); });
// Poll every 3s while any task is active
$effect(() => {
const hasActive = tasks.some((t) => t.status === 'running' || t.status === 'pending');
if (!hasActive) return;
const timer = setInterval(() => { loadTasks(); }, 3000);
return () => clearInterval(timer);
});
// Poll every 3s while any task is running
// When a running task finishes, surface the AI panel for it
$effect(() => {
const hasRunning = tasks.some((t) => t.status === 'running' || t.status === 'pending');
if (!hasRunning) return;
const timer = setInterval(() => {
loadTasks();
}, 3000);
return () => clearInterval(timer);
if (!showAiPanel) {
const done = tasks.find((t) => t.status === 'done');
if (done && !aiSlug) {
aiSlug = done.slug;
aiTitle = done.title;
showAiPanel = true;
}
}
});
</script>
<div class="max-w-4xl">
<h1 class="text-2xl font-bold mb-6">Import PDF/EPUB</h1>
<div class="max-w-3xl space-y-8">
<h1 class="text-2xl font-bold">Import PDF/EPUB</h1>
{#if pendingImport}
<!-- Review Step -->
<div class="mb-8 p-6 bg-(--color-surface-2) rounded-lg border border-(--color-brand)/30">
<h2 class="text-lg font-semibold mb-4">Review Import</h2>
<div class="space-y-3 mb-6">
<div class="flex justify-between">
<span class="text-(--color-muted)">Title:</span>
<span class="font-medium">{pendingImport.title}</span>
<!-- ── Review step ── -->
<div class="p-6 bg-(--color-surface-2) rounded-lg border border-(--color-brand)/30 space-y-4">
<h2 class="text-lg font-semibold">Review Import</h2>
<dl class="space-y-2 text-sm">
<div class="flex justify-between gap-4">
<dt class="text-(--color-muted) shrink-0">Title</dt>
<dd class="font-medium text-right">{pendingImport.title}</dd>
</div>
<div class="flex justify-between">
<span class="text-(--color-muted)">File:</span>
<span>{pendingImport.file.name}</span>
</div>
<div class="flex justify-between">
<span class="text-(--color-muted)">Size:</span>
<span>{(pendingImport.file.size / 1024 / 1024).toFixed(2)} MB</span>
</div>
{#if pendingImport.preview.chapters > 0}
<div class="flex justify-between">
<span class="text-(--color-muted)">Detected chapters:</span>
<span class="text-green-400">{pendingImport.preview.chapters}</span>
{#if pendingImport.author}
<div class="flex justify-between gap-4">
<dt class="text-(--color-muted) shrink-0">Author</dt>
<dd class="text-right">{pendingImport.author}</dd>
</div>
{/if}
</div>
<div class="flex gap-3">
{#if pendingImport.genres}
<div class="flex justify-between gap-4">
<dt class="text-(--color-muted) shrink-0">Genres</dt>
<dd class="text-right">{pendingImport.genres}</dd>
</div>
{/if}
<div class="flex justify-between gap-4">
<dt class="text-(--color-muted) shrink-0">Status</dt>
<dd class="capitalize text-right">{pendingImport.bookStatus}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-(--color-muted) shrink-0">File</dt>
<dd class="text-right truncate max-w-xs">{pendingImport.file.name}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-(--color-muted) shrink-0">Size</dt>
<dd>{(pendingImport.file.size / 1024 / 1024).toFixed(2)} MB</dd>
</div>
{#if pendingImport.preview.chapters > 0}
<div class="flex justify-between gap-4">
<dt class="text-(--color-muted) shrink-0">Detected chapters</dt>
<dd class="text-green-400 font-semibold">{pendingImport.preview.chapters}</dd>
</div>
{/if}
</dl>
{#if pendingImport.preview.firstLines?.length}
<div class="mt-2 space-y-1">
<p class="text-xs text-(--color-muted) mb-1">First lines preview:</p>
{#each pendingImport.preview.firstLines as line}
<p class="text-xs text-(--color-muted) italic truncate">{line}</p>
{/each}
</div>
{/if}
<div class="flex gap-3 pt-2">
<button
onclick={startImport}
disabled={uploading}
class="px-4 py-2 bg-green-600 text-white rounded font-medium disabled:opacity-50"
class="px-4 py-2 bg-green-600 hover:bg-green-500 text-white rounded font-medium disabled:opacity-50 transition-colors"
>
{uploading ? 'Starting...' : 'Start Import'}
{uploading ? 'Starting' : 'Start Import'}
</button>
<button
onclick={cancelReview}
class="px-4 py-2 border border-(--color-border) rounded font-medium"
class="px-4 py-2 border border-(--color-border) rounded font-medium hover:bg-(--color-surface-3) transition-colors"
>
Cancel
</button>
</div>
</div>
{:else}
<!-- Upload Form -->
<form onsubmit={(e) => { e.preventDefault(); analyzeFile(); }} class="mb-8 p-4 bg-(--color-surface-2) rounded-lg">
<div class="mb-4">
<label for="import-file" class="block text-sm font-medium mb-2">Select File (PDF or EPUB)</label>
<!-- ── Upload form ── -->
<form
onsubmit={(e) => { e.preventDefault(); analyzeFile(); }}
class="p-6 bg-(--color-surface-2) rounded-lg space-y-4"
>
<!-- File picker -->
<div>
<label for="import-file" class="block text-sm font-medium mb-1">File (PDF or EPUB)</label>
<input
id="import-file"
type="file"
accept=".pdf,.epub"
onchange={handleFileSelect}
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text)"
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
/>
</div>
<div class="mb-4">
<label for="import-title" class="block text-sm font-medium mb-2">Book Title</label>
<!-- Title -->
<div>
<label for="import-title" class="block text-sm font-medium mb-1">Title <span class="text-red-400">*</span></label>
<input
id="import-title"
type="text"
bind:value={title}
placeholder="Enter book title"
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text)"
placeholder="Book title"
required
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
/>
</div>
<!-- Author -->
<div>
<label for="import-author" class="block text-sm font-medium mb-1">Author</label>
<input
id="import-author"
type="text"
bind:value={author}
placeholder="Author name"
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
/>
</div>
<!-- Cover URL -->
<div>
<label for="import-cover" class="block text-sm font-medium mb-1">Cover image URL</label>
<input
id="import-cover"
type="url"
bind:value={coverUrl}
placeholder="https://…"
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
/>
</div>
<!-- Genres -->
<div>
<label for="import-genres" class="block text-sm font-medium mb-1">Genres <span class="text-xs text-(--color-muted)">(comma-separated)</span></label>
<input
id="import-genres"
type="text"
bind:value={genres}
placeholder="Fantasy, Action, Romance"
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
/>
</div>
<!-- Summary -->
<div>
<label for="import-summary" class="block text-sm font-medium mb-1">Summary</label>
<textarea
id="import-summary"
bind:value={summary}
rows={3}
placeholder="Short description of the book…"
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm resize-y"
></textarea>
</div>
<!-- Status -->
<div>
<label for="import-status" class="block text-sm font-medium mb-1">Book status</label>
<select
id="import-status"
bind:value={bookStatus}
class="px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
>
<option value="completed">Completed</option>
<option value="ongoing">Ongoing</option>
<option value="hiatus">Hiatus</option>
</select>
</div>
{#if error}
<p class="mb-4 text-sm text-red-400">{error}</p>
<p class="text-sm text-red-400">{error}</p>
{/if}
<button
type="submit"
disabled={analyzing || !selectedFile || !title.trim()}
class="px-4 py-2 bg-(--color-brand) text-(--color-surface) rounded font-medium disabled:opacity-50"
class="px-5 py-2 bg-(--color-brand) text-(--color-surface) rounded font-semibold disabled:opacity-50 hover:brightness-110 transition-all"
>
{analyzing ? 'Analyzing...' : 'Review & Import'}
{analyzing ? 'Analyzing' : 'Review & Import'}
</button>
<p class="mt-2 text-xs text-(--color-muted)">
Select a file to preview chapter count before importing.
</p>
<p class="text-xs text-(--color-muted)">Detects chapter structure before committing.</p>
</form>
{/if}
<!-- Task List -->
<h2 class="text-lg font-semibold mb-4">Import Tasks</h2>
{#if loading}
<p class="text-(--color-muted)">Loading...</p>
{:else if tasks.length === 0}
<p class="text-(--color-muted)">No import tasks yet.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-(--color-muted) border-b border-(--color-border)">
<th class="pb-2">Title</th>
<th class="pb-2">Type</th>
<th class="pb-2">Status</th>
<th class="pb-2">Chapters</th>
<th class="pb-2">Started</th>
</tr>
</thead>
<tbody>
{#each tasks as task}
<tr class="border-b border-(--color-border)/50">
<td class="py-2">
<div class="font-medium">{task.title}</div>
<div class="text-xs text-(--color-muted)">{task.slug}</div>
</td>
<td class="py-2 uppercase text-xs">{task.file_type}</td>
<td class="py-2 {getStatusColor(task.status)}">{task.status}</td>
<td class="py-2 text-(--color-muted)">
{task.chapters_done}/{task.chapters_total}
</td>
<td class="py-2 text-(--color-muted)">{formatDate(task.started)}</td>
</tr>
{/each}
</tbody>
</table>
<!-- ── AI Tasks panel (shown after successful import) ── -->
{#if showAiPanel && aiSlug}
<div class="p-5 bg-(--color-surface-2) rounded-lg border border-(--color-brand)/20 space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-base font-semibold">AI Tasks for <span class="text-(--color-brand)">{aiTitle || aiSlug}</span></h2>
<button
onclick={() => { showAiPanel = false; }}
class="text-(--color-muted) hover:text-(--color-text) text-lg leading-none"
aria-label="Dismiss"
>&times;</button>
</div>
<p class="text-sm text-(--color-muted)">Run AI tasks on the imported book to enrich it:</p>
<div class="flex flex-wrap gap-2">
<a
href="/admin/text-gen?slug={aiSlug}&tab=chapters"
class="px-3 py-1.5 text-sm rounded bg-(--color-surface-3) hover:bg-(--color-brand)/20 border border-(--color-border) transition-colors"
>
Generate chapter names
</a>
<a
href="/admin/text-gen?slug={aiSlug}&tab=description"
class="px-3 py-1.5 text-sm rounded bg-(--color-surface-3) hover:bg-(--color-brand)/20 border border-(--color-border) transition-colors"
>
Generate description
</a>
<a
href="/admin/image-gen?slug={aiSlug}"
class="px-3 py-1.5 text-sm rounded bg-(--color-surface-3) hover:bg-(--color-brand)/20 border border-(--color-border) transition-colors"
>
Generate cover image
</a>
<a
href="/admin/text-gen?slug={aiSlug}&tab=tagline"
class="px-3 py-1.5 text-sm rounded bg-(--color-surface-3) hover:bg-(--color-brand)/20 border border-(--color-border) transition-colors"
>
Generate tagline
</a>
</div>
</div>
{/if}
</div>
<!-- ── Task list ── -->
<div>
<h2 class="text-lg font-semibold mb-3">Import Tasks</h2>
{#if loading}
<p class="text-(--color-muted) text-sm">Loading…</p>
{:else if tasks.length === 0}
<p class="text-(--color-muted) text-sm">No import tasks yet.</p>
{:else}
<div class="overflow-x-auto rounded-lg border border-(--color-border)">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-(--color-muted) border-b border-(--color-border) bg-(--color-surface-2)">
<th class="px-3 py-2 font-medium">Title</th>
<th class="px-3 py-2 font-medium">Type</th>
<th class="px-3 py-2 font-medium">Status</th>
<th class="px-3 py-2 font-medium">Chapters</th>
<th class="px-3 py-2 font-medium">Started</th>
<th class="px-3 py-2 font-medium">AI</th>
</tr>
</thead>
<tbody>
{#each tasks as task}
<tr class="border-b border-(--color-border)/50 hover:bg-(--color-surface-2)/50">
<td class="px-3 py-2">
<div class="font-medium">{task.title}</div>
<div class="text-xs text-(--color-muted)">{task.slug}</div>
{#if task.error_message}
<div class="text-xs text-red-400 mt-0.5 truncate max-w-xs" title={task.error_message}>{task.error_message}</div>
{/if}
</td>
<td class="px-3 py-2 uppercase text-xs">{task.file_type}</td>
<td class="px-3 py-2 {statusColor(task.status)} font-medium">{task.status}</td>
<td class="px-3 py-2 text-(--color-muted)">
{task.chapters_done}/{task.chapters_total}
</td>
<td class="px-3 py-2 text-(--color-muted) text-xs whitespace-nowrap">{formatDate(task.started)}</td>
<td class="px-3 py-2">
{#if task.status === 'done'}
<button
onclick={() => { aiSlug = task.slug; aiTitle = task.title; showAiPanel = true; }}
class="text-xs px-2 py-1 rounded bg-(--color-brand)/20 hover:bg-(--color-brand)/40 text-(--color-brand) transition-colors"
>
AI tasks
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>

View File

@@ -1,15 +1,24 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { parseAuthToken } from '../../../../hooks.server.js';
import { deleteSessionByAuthId } from '$lib/server/pocketbase';
const AUTH_COOKIE = 'libnovel_auth';
/**
* POST /api/auth/logout
* Clears the auth cookie and returns { ok: true }.
* Does not revoke the session record from PocketBase —
* for full revocation use DELETE /api/sessions/[id] first.
* Deletes the session row from PocketBase AND clears the auth cookie, so the
* session doesn't linger as a phantom "active session" after sign-out.
*/
export const POST: RequestHandler = async ({ cookies }) => {
const token = cookies.get(AUTH_COOKIE);
if (token) {
const user = parseAuthToken(token);
if (user?.authSessionId) {
// Best-effort — non-fatal if PocketBase is unreachable.
deleteSessionByAuthId(user.authSessionId).catch(() => {});
}
}
cookies.delete(AUTH_COOKIE, { path: '/' });
return json({ ok: true });
};