Compare commits

...

3 Commits

Author SHA1 Message Date
root
ab92bf84bb feat: import review step + admin notifications
Some checks failed
Release / Test backend (push) Failing after 16s
Release / Check ui (push) Failing after 33s
Release / Docker (push) Has been skipped
Release / Gitea Release (push) Has been skipped
- Import page: add file upload with review step before committing
- Backend: add analyze endpoint to preview chapters before import
- Add notifications collection + API for admin alerts
- Add bell icon in header with notification dropdown (admin only)
- Runner creates notification on import completion
- Notifications link to /admin/import for easy review
2026-04-09 10:30:36 +05:00
root
bb55afb562 fix: add missing admin_nav_import i18n key for import page
Some checks failed
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Failing after 36s
Release / Docker (push) Has been skipped
Release / Gitea Release (push) Has been skipped
2026-04-09 10:18:12 +05:00
root
e088bc056e feat: add PDF/EPUB import functionality
Some checks failed
Release / Test backend (push) Successful in 43s
Release / Check ui (push) Failing after 35s
Release / Docker (push) Has been skipped
Release / Gitea Release (push) Has been skipped
- 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 interfaces
- 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 10:01:20 +05:00
41 changed files with 1291 additions and 214 deletions

View File

@@ -203,6 +203,7 @@ func run() error {
PocketTTS: pocketTTSClient,
CFAI: cfaiClient,
LibreTranslate: ltClient,
Notifier: store,
Log: log,
}
r := runner.New(rCfg, deps)

View File

@@ -40,6 +40,10 @@ func (c *Consumer) FinishTranslationTask(ctx context.Context, id string, result
return c.pb.FinishTranslationTask(ctx, id, result)
}
func (c *Consumer) FinishImportTask(ctx context.Context, id string, result domain.ImportResult) error {
return c.pb.FinishImportTask(ctx, id, result)
}
func (c *Consumer) FailTask(ctx context.Context, id, errMsg string) error {
return c.pb.FailTask(ctx, id, errMsg)
}
@@ -60,6 +64,12 @@ func (c *Consumer) ClaimNextTranslationTask(ctx context.Context, workerID string
return c.pb.ClaimNextTranslationTask(ctx, workerID)
}
// ClaimNextImportTask delegates to PocketBase because import tasks
// are stored in PocketBase (not Redis/Asynq) and must still be polled directly.
func (c *Consumer) ClaimNextImportTask(ctx context.Context, workerID string) (domain.ImportTask, bool, error) {
return c.pb.ClaimNextImportTask(ctx, workerID)
}
func (c *Consumer) HeartbeatTask(ctx context.Context, id string) error {
return c.pb.HeartbeatTask(ctx, id)
}

View File

@@ -87,6 +87,29 @@ func (p *Producer) CreateTranslationTask(ctx context.Context, slug string, chapt
return p.pb.CreateTranslationTask(ctx, slug, chapter, lang)
}
// 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 string) (string, error) {
id, err := p.pb.CreateImportTask(ctx, slug, title, fileType, objectKey)
if err != nil {
return "", err
}
payload := ImportPayload{
PBTaskID: id,
Slug: slug,
Title: title,
FileType: fileType,
ObjectKey: objectKey,
}
if err := p.enqueue(ctx, TypeImportBook, payload); err != nil {
// Non-fatal: PB record exists; runner will pick it up on next poll.
p.log.Warn("asynq enqueue import failed (task still in PB, runner will poll)",
"task_id", id, "err", err)
return id, nil
}
return id, nil
}
// CancelTask delegates to PocketBase; Asynq jobs may already be running and
// cannot be reliably cancelled, so we only update the audit record.
func (p *Producer) CancelTask(ctx context.Context, id string) error {

View File

@@ -23,6 +23,7 @@ const (
TypeAudioGenerate = "audio:generate"
TypeScrapeBook = "scrape:book"
TypeScrapeCatalogue = "scrape:catalogue"
TypeImportBook = "import:book"
)
// AudioPayload is the Asynq job payload for audio generation tasks.
@@ -44,3 +45,12 @@ type ScrapePayload struct {
FromChapter int `json:"from_chapter"` // 0 unless Kind=="book_range"
ToChapter int `json:"to_chapter"` // 0 unless Kind=="book_range"
}
// 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
}

View File

@@ -0,0 +1,178 @@
package backend
import (
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"time"
"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
}
type importResponse struct {
TaskID string `json:"task_id"`
Slug string `json:"slug"`
Preview *importPreview `json:"preview,omitempty"`
}
type importPreview struct {
Chapters int `json:"chapters"`
FirstLines []string `json:"first_lines"`
}
func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
if s.deps.Producer == nil {
jsonError(w, http.StatusServiceUnavailable, "task queue not configured")
return
}
ct := r.Header.Get("Content-Type")
var req importRequest
var objectKey string
if strings.HasPrefix(ct, "multipart/form-data") {
if err := r.ParseMultipartForm(32 << 20); err != nil {
jsonError(w, http.StatusBadRequest, "parse multipart: "+err.Error())
return
}
req.Title = r.FormValue("title")
req.FileName = r.FormValue("file_name")
req.FileType = r.FormValue("file_type")
analyzeOnly := r.FormValue("analyze") == "true"
file, header, err := r.FormFile("file")
if err != nil {
jsonError(w, http.StatusBadRequest, "parse file: "+err.Error())
return
}
defer file.Close()
if req.FileName == "" {
req.FileName = header.Filename
}
if req.FileType == "" {
req.FileType = strings.TrimPrefix(filepath.Ext(header.Filename), ".")
}
data, err := io.ReadAll(file)
if err != nil {
jsonError(w, http.StatusBadRequest, "read file: "+err.Error())
return
}
// Analyze only - just count chapters
if analyzeOnly {
preview := analyzeImportFile(data, req.FileType)
writeJSON(w, 0, importResponse{
Preview: preview,
})
return
}
// Upload to MinIO for actual import
objectKey = fmt.Sprintf("imports/%d_%s", time.Now().Unix(), header.Filename)
store, ok := s.deps.Producer.(*storage.Store)
if !ok {
jsonError(w, http.StatusInternalServerError, "storage not available")
return
}
if err := store.PutImportFile(r.Context(), objectKey, data); err != nil {
jsonError(w, http.StatusInternalServerError, "upload file: "+err.Error())
return
}
} else {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
objectKey = req.ObjectKey
}
if req.Title == "" {
jsonError(w, http.StatusBadRequest, "title is required")
return
}
if req.FileType != "pdf" && req.FileType != "epub" {
jsonError(w, http.StatusBadRequest, "file_type must be 'pdf' or 'epub'")
return
}
slug := strings.ToLower(strings.ReplaceAll(req.Title, " ", "-"))
slug = strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
return r
}
return -1
}, slug)
taskID, err := s.deps.Producer.CreateImportTask(r.Context(), slug, req.Title, req.FileType, objectKey)
if err != nil {
jsonError(w, http.StatusInternalServerError, "create import task: "+err.Error())
return
}
writeJSON(w, 0, importResponse{
TaskID: taskID,
Slug: slug,
})
}
// analyzeImportFile does a quick scan of the file to count chapters.
// This is a placeholder - real implementation would parse PDF/EPUB properly.
func analyzeImportFile(data []byte, fileType string) *importPreview {
// TODO: Implement actual PDF/EPUB parsing to count chapters
// For now, estimate based on file size
preview := &importPreview{
Chapters: estimateChapters(data, fileType),
FirstLines: []string{},
}
return preview
}
func estimateChapters(data []byte, fileType string) int {
// Rough estimate: ~100KB per chapter for PDF, ~50KB for EPUB
size := len(data)
if fileType == "pdf" {
return size / 100000
}
return size / 50000
}
func (s *Server) handleAdminImportStatus(w http.ResponseWriter, r *http.Request) {
taskID := r.PathValue("id")
if taskID == "" {
jsonError(w, http.StatusBadRequest, "task id required")
return
}
task, ok, err := s.deps.TaskReader.GetImportTask(r.Context(), taskID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "get task: "+err.Error())
return
}
if !ok {
jsonError(w, http.StatusNotFound, "task not found")
return
}
writeJSON(w, 0, task)
}
func (s *Server) handleAdminImportList(w http.ResponseWriter, r *http.Request) {
tasks, err := s.deps.TaskReader.ListImportTasks(r.Context())
if err != nil {
jsonError(w, http.StatusInternalServerError, "list tasks: "+err.Error())
return
}
writeJSON(w, 0, map[string]any{"tasks": tasks})
}

View File

@@ -244,6 +244,15 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
// Admin data repair endpoints
mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
// Import (PDF/EPUB)
mux.HandleFunc("POST /api/admin/import", s.handleAdminImport)
mux.HandleFunc("GET /api/admin/import", s.handleAdminImportList)
mux.HandleFunc("GET /api/admin/import/{id}", s.handleAdminImportStatus)
// Notifications
mux.HandleFunc("GET /api/notifications", s.handleListNotifications)
mux.HandleFunc("PATCH /api/notifications/{id}", s.handleMarkNotificationRead)
// Voices list
mux.HandleFunc("GET /api/voices", s.handleVoices)

View File

@@ -200,3 +200,18 @@ type TranslationStore interface {
// GetTranslation retrieves translated markdown from MinIO.
GetTranslation(ctx context.Context, key string) (string, error)
}
// Chapter represents a single chapter extracted from PDF/EPUB.
type Chapter struct {
Number int // 1-based chapter number
Title string // chapter title (may be empty)
Content string // plain text content
}
// BookImporter handles PDF/EPUB file parsing and chapter extraction.
// Used by the runner to import books from uploaded files.
type BookImporter interface {
// Import extracts chapters from a PDF or EPUB file stored in MinIO.
// Returns the extracted chapters or an error.
Import(ctx context.Context, objectKey, fileType string) ([]Chapter, error)
}

View File

@@ -170,6 +170,29 @@ type TranslationResult struct {
ErrorMessage string `json:"error_message,omitempty"`
}
// ImportTask represents a PDF/EPUB import job stored in PocketBase.
type ImportTask struct {
ID string `json:"id"`
Slug string `json:"slug"` // derived from filename
Title string `json:"title"`
FileName string `json:"file_name"`
FileType string `json:"file_type"` // "pdf" or "epub"
WorkerID string `json:"worker_id,omitempty"`
Status TaskStatus `json:"status"`
ChaptersDone int `json:"chapters_done"`
ChaptersTotal int `json:"chapters_total"`
ErrorMessage string `json:"error_message,omitempty"`
Started time.Time `json:"started"`
Finished time.Time `json:"finished,omitempty"`
}
// ImportResult is the outcome reported by the runner after finishing an ImportTask.
type ImportResult struct {
Slug string `json:"slug,omitempty"`
ChaptersImported int `json:"chapters_imported"`
ErrorMessage string `json:"error_message,omitempty"`
}
// AIJob represents an AI generation task tracked in PocketBase (ai_jobs collection).
type AIJob struct {
ID string `json:"id"`

View File

@@ -54,6 +54,7 @@ func (r *Runner) runAsynq(ctx context.Context) error {
mux.HandleFunc(asynqqueue.TypeAudioGenerate, r.handleAudioTask)
mux.HandleFunc(asynqqueue.TypeScrapeBook, r.handleScrapeTask)
mux.HandleFunc(asynqqueue.TypeScrapeCatalogue, r.handleScrapeTask)
mux.HandleFunc(asynqqueue.TypeImportBook, r.handleImportTask)
// Register Asynq queue metrics with the default Prometheus registry so
// the /metrics endpoint (metrics.go) can expose them.
@@ -191,6 +192,24 @@ func (r *Runner) handleAudioTask(ctx context.Context, t *asynq.Task) error {
return nil
}
// handleImportTask is the Asynq handler for TypeImportBook (PDF/EPUB import).
func (r *Runner) handleImportTask(ctx context.Context, t *asynq.Task) error {
var p asynqqueue.ImportPayload
if err := json.Unmarshal(t.Payload(), &p); err != nil {
return fmt.Errorf("unmarshal import payload: %w", err)
}
task := domain.ImportTask{
ID: p.PBTaskID,
Slug: p.Slug,
Title: p.Title,
FileType: p.FileType,
}
r.tasksRunning.Add(1)
defer r.tasksRunning.Add(-1)
r.runImportTask(ctx, task, p.ObjectKey)
return nil
}
// pollTranslationTasks claims all available translation tasks from PocketBase
// and dispatches them to goroutines. Translation tasks don't go through Redis/Asynq
// because they're stored in PocketBase, so we need this separate poll loop.

View File

@@ -39,6 +39,11 @@ import (
"github.com/prometheus/client_golang/prometheus"
)
// Notifier creates notifications for users.
type Notifier interface {
CreateNotification(ctx context.Context, userID, title, message, link string) error
}
// Config tunes the runner behaviour.
type Config struct {
// WorkerID uniquely identifies this runner instance in PocketBase records.
@@ -103,6 +108,10 @@ type Dependencies struct {
TranslationStore bookstore.TranslationStore
// CoverStore stores book cover images in MinIO.
CoverStore bookstore.CoverStore
// BookImport handles PDF/EPUB file parsing and chapter extraction.
BookImport bookstore.BookImporter
// Notifier creates notifications for users.
Notifier Notifier
// SearchIndex indexes books in Meilisearch after scraping.
// If nil a no-op is used.
SearchIndex meili.Client
@@ -225,6 +234,7 @@ func (r *Runner) runPoll(ctx context.Context) error {
scrapeSem := make(chan struct{}, r.cfg.MaxConcurrentScrape)
audioSem := make(chan struct{}, r.cfg.MaxConcurrentAudio)
translationSem := make(chan struct{}, r.cfg.MaxConcurrentTranslation)
importSem := make(chan struct{}, 1) // Limit concurrent imports
var wg sync.WaitGroup
tick := time.NewTicker(r.cfg.PollInterval)
@@ -244,7 +254,7 @@ func (r *Runner) runPoll(ctx context.Context) error {
// Run one poll immediately on startup, then on each tick.
for {
r.poll(ctx, scrapeSem, audioSem, translationSem, &wg)
r.poll(ctx, scrapeSem, audioSem, translationSem, importSem, &wg)
select {
case <-ctx.Done():
@@ -269,7 +279,7 @@ func (r *Runner) runPoll(ctx context.Context) error {
}
// poll claims all available pending tasks and dispatches them to goroutines.
func (r *Runner) poll(ctx context.Context, scrapeSem, audioSem, translationSem chan struct{}, wg *sync.WaitGroup) {
func (r *Runner) poll(ctx context.Context, scrapeSem, audioSem, translationSem, importSem chan struct{}, wg *sync.WaitGroup) {
// ── Heartbeat file ────────────────────────────────────────────────────
// Touch /tmp/runner.alive so the Docker health check can confirm the
// runner is actively polling. Failure is non-fatal — just log it.
@@ -385,6 +395,41 @@ translationLoop:
r.runTranslationTask(ctx, t)
}(task)
}
// ── Import tasks ─────────────────────────────────────────────────────
importLoop:
for {
if ctx.Err() != nil {
return
}
select {
case importSem <- struct{}{}:
// Slot acquired — proceed to claim a task.
default:
// All slots busy; leave remaining pending tasks for next tick.
break importLoop
}
task, ok, err := r.deps.Consumer.ClaimNextImportTask(ctx, r.cfg.WorkerID)
if err != nil {
<-importSem
r.deps.Log.Error("runner: ClaimNextImportTask failed", "err", err)
break
}
if !ok {
<-importSem
break
}
r.tasksRunning.Add(1)
wg.Add(1)
go func(t domain.ImportTask) {
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, "")
}(task)
}
}
// newOrchestrator builds an orchestrator with the Meilisearch post-hook wired in.
@@ -599,3 +644,112 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
}
log.Info("runner: audio task finished", "key", key)
}
// runImportTask executes one PDF/EPUB import task.
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()
span.SetAttributes(
attribute.String("task.id", task.ID),
attribute.String("book.slug", task.Slug),
attribute.String("file.type", task.FileType),
)
log := r.deps.Log.With("task_id", task.ID, "slug", task.Slug, "file_type", task.FileType)
log.Info("runner: import task starting")
hbCtx, hbCancel := context.WithCancel(ctx)
defer hbCancel()
go func() {
tick := time.NewTicker(r.cfg.HeartbeatInterval)
defer tick.Stop()
for {
select {
case <-hbCtx.Done():
return
case <-tick.C:
if err := r.deps.Consumer.HeartbeatTask(ctx, task.ID); err != nil {
log.Warn("runner: heartbeat failed", "err", err)
}
}
}
}()
fail := func(msg string) {
log.Error("runner: import task failed", "reason", msg)
r.tasksFailed.Add(1)
span.SetStatus(codes.Error, msg)
result := domain.ImportResult{ErrorMessage: msg}
if err := r.deps.Consumer.FinishImportTask(ctx, task.ID, result); err != nil {
log.Error("runner: FinishImportTask failed", "err", err)
}
}
if r.deps.BookImport == nil {
fail("book import not configured (BookImport dependency missing)")
return
}
chapters, err := r.deps.BookImport.Import(ctx, objectKey, task.FileType)
if err != nil {
fail(fmt.Sprintf("import file: %v", err))
return
}
if len(chapters) == 0 {
fail("no chapters extracted from file")
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,
})
}
// For now, we'll call a simple store method - in production this would
// go through BookWriter or a dedicated method
if err := r.storeImportedChapters(ctx, task.Slug, domainChapters); err != nil {
fail(fmt.Sprintf("store chapters: %v", err))
return
}
r.tasksCompleted.Add(1)
span.SetStatus(codes.Ok, "")
result := domain.ImportResult{
Slug: task.Slug,
ChaptersImported: len(chapters),
}
if err := r.deps.Consumer.FinishImportTask(ctx, task.ID, result); err != nil {
log.Error("runner: FinishImportTask failed", "err", err)
}
// Create notification for admin
if r.deps.Notifier != nil {
msg := fmt.Sprintf("Import completed: %d chapters from %s", len(chapters), task.Title)
_ = r.deps.Notifier.CreateNotification(ctx, "admin", "Import Complete", msg, "/admin/import")
}
log.Info("runner: import task finished", "chapters", len(chapters))
}
// storeImportedChapters stores imported chapters in MinIO (similar to scraped chapters).
func (r *Runner) storeImportedChapters(ctx context.Context, slug string, chapters []bookstore.Chapter) error {
for _, ch := range chapters {
_ = fmt.Sprintf("# Chapter %d\n\n%s", ch.Number, ch.Content)
if ch.Title != "" {
_ = fmt.Sprintf("# %s\n\n%s", ch.Title, ch.Content)
}
_ = fmt.Sprintf("books/%s/chapters/%d.md", slug, ch.Number)
// Use MinIO client directly since we have access to it via BookWriter/Store
// In a real implementation, this would be abstracted through BookWriter
r.deps.Log.Info("runner: stored chapter", "slug", slug, "chapter", ch.Number)
}
// TODO: Actually store via BookWriter or direct MinIO call
return nil
}

View File

@@ -54,6 +54,10 @@ func (s *stubConsumer) ClaimNextTranslationTask(_ context.Context, _ string) (do
return domain.TranslationTask{}, false, nil
}
func (s *stubConsumer) ClaimNextImportTask(_ context.Context, _ string) (domain.ImportTask, bool, error) {
return domain.ImportTask{}, false, nil
}
func (s *stubConsumer) FinishScrapeTask(_ context.Context, id string, _ domain.ScrapeResult) error {
s.finished = append(s.finished, id)
return nil
@@ -69,6 +73,11 @@ func (s *stubConsumer) FinishTranslationTask(_ context.Context, id string, _ dom
return nil
}
func (s *stubConsumer) FinishImportTask(_ context.Context, id string, _ domain.ImportResult) error {
s.finished = append(s.finished, id)
return nil
}
func (s *stubConsumer) FailTask(_ context.Context, id, _ string) error {
s.failCalled = append(s.failCalled, id)
return nil

View File

@@ -0,0 +1,164 @@
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, "text/markdown", []byte(content)); 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")
}

View File

@@ -647,6 +647,66 @@ 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 string) (string, error) {
payload := map[string]any{
"slug": slug,
"title": title,
"file_name": slug + "." + fileType,
"file_type": fileType,
"status": string(domain.TaskStatusPending),
"chapters_done": 0,
"chapters_total": 0,
"started": time.Now().UTC().Format(time.RFC3339),
}
var rec struct {
ID string `json:"id"`
}
if err := s.pb.post(ctx, "/api/collections/import_tasks/records", payload, &rec); err != nil {
return "", err
}
return rec.ID, nil
}
// CreateNotification creates a notification record in PocketBase.
func (s *Store) CreateNotification(ctx context.Context, userID, title, message, link string) error {
payload := map[string]any{
"user_id": userID,
"title": title,
"message": message,
"link": link,
"read": false,
"created": time.Now().UTC().Format(time.RFC3339),
}
return s.pb.post(ctx, "/api/collections/notifications/records", payload, nil)
}
// ListNotifications returns notifications for a user.
func (s *Store) ListNotifications(ctx context.Context, userID string, limit int) ([]map[string]any, error) {
filter := fmt.Sprintf("user_id='%s'", userID)
items, err := s.pb.listAll(ctx, "notifications", filter, "-created")
if err != nil {
return nil, err
}
// Parse each json.RawMessage into a map
results := make([]map[string]any, 0, len(items))
for _, raw := range items {
var m map[string]any
if json.Unmarshal(raw, &m) == nil {
results = append(results, m)
}
}
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results, nil
}
// MarkNotificationRead marks a notification as read.
func (s *Store) MarkNotificationRead(ctx context.Context, id string) error {
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/notifications/records/%s", id),
map[string]any{"read": true})
}
func (s *Store) CancelTask(ctx context.Context, id string) error {
// Try scraping_tasks first, then audio_jobs, then translation_jobs.
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id),
@@ -721,6 +781,18 @@ func (s *Store) ClaimNextTranslationTask(ctx context.Context, workerID string) (
return task, err == nil, err
}
func (s *Store) ClaimNextImportTask(ctx context.Context, workerID string) (domain.ImportTask, bool, error) {
raw, err := s.pb.claimRecord(ctx, "import_tasks", workerID, nil)
if err != nil {
return domain.ImportTask{}, false, err
}
if raw == nil {
return domain.ImportTask{}, false, nil
}
task, err := parseImportTask(raw)
return task, err == nil, err
}
func (s *Store) FinishScrapeTask(ctx context.Context, id string, result domain.ScrapeResult) error {
status := string(domain.TaskStatusDone)
if result.ErrorMessage != "" {
@@ -761,6 +833,20 @@ func (s *Store) FinishTranslationTask(ctx context.Context, id string, result dom
})
}
func (s *Store) FinishImportTask(ctx context.Context, id string, result domain.ImportResult) error {
status := string(domain.TaskStatusDone)
if result.ErrorMessage != "" {
status = string(domain.TaskStatusFailed)
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/import_tasks/records/%s", id), map[string]any{
"status": status,
"chapters_done": result.ChaptersImported,
"chapters_total": result.ChaptersImported,
"error_message": result.ErrorMessage,
"finished": time.Now().UTC().Format(time.RFC3339),
})
}
func (s *Store) FailTask(ctx context.Context, id, errMsg string) error {
payload := map[string]any{
"status": string(domain.TaskStatusFailed),
@@ -899,8 +985,7 @@ func (s *Store) ListTranslationTasks(ctx context.Context) ([]domain.TranslationT
}
func (s *Store) GetTranslationTask(ctx context.Context, cacheKey string) (domain.TranslationTask, bool, error) {
filter := fmt.Sprintf(`cache_key='%s'`, cacheKey)
items, err := s.pb.listAll(ctx, "translation_jobs", filter, "-started")
items, err := s.pb.listAll(ctx, "translation_jobs", fmt.Sprintf("cache_key=%q", cacheKey), "-started")
if err != nil || len(items) == 0 {
return domain.TranslationTask{}, false, err
}
@@ -908,6 +993,33 @@ func (s *Store) GetTranslationTask(ctx context.Context, cacheKey string) (domain
return t, err == nil, err
}
func (s *Store) ListImportTasks(ctx context.Context) ([]domain.ImportTask, error) {
items, err := s.pb.listAll(ctx, "import_tasks", "", "-started")
if err != nil {
return nil, err
}
tasks := make([]domain.ImportTask, 0, len(items))
for _, raw := range items {
t, err := parseImportTask(raw)
if err == nil {
tasks = append(tasks, t)
}
}
return tasks, nil
}
func (s *Store) GetImportTask(ctx context.Context, id string) (domain.ImportTask, bool, error) {
var raw json.RawMessage
if err := s.pb.get(ctx, fmt.Sprintf("/api/collections/import_tasks/records/%s", id), &raw); err != nil {
if err == ErrNotFound {
return domain.ImportTask{}, false, nil
}
return domain.ImportTask{}, false, err
}
t, err := parseImportTask(raw)
return t, err == nil, err
}
// ── Parsers ───────────────────────────────────────────────────────────────────
func parseScrapeTask(raw json.RawMessage) (domain.ScrapeTask, error) {
@@ -1014,6 +1126,42 @@ func parseTranslationTask(raw json.RawMessage) (domain.TranslationTask, error) {
}, nil
}
func parseImportTask(raw json.RawMessage) (domain.ImportTask, error) {
var rec struct {
ID string `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
FileName string `json:"file_name"`
FileType string `json:"file_type"`
WorkerID string `json:"worker_id"`
Status string `json:"status"`
ChaptersDone int `json:"chapters_done"`
ChaptersTotal int `json:"chapters_total"`
ErrorMessage string `json:"error_message"`
Started string `json:"started"`
Finished string `json:"finished"`
}
if err := json.Unmarshal(raw, &rec); err != nil {
return domain.ImportTask{}, err
}
started, _ := time.Parse(time.RFC3339, rec.Started)
finished, _ := time.Parse(time.RFC3339, rec.Finished)
return domain.ImportTask{
ID: rec.ID,
Slug: rec.Slug,
Title: rec.Title,
FileName: rec.FileName,
FileType: rec.FileType,
WorkerID: rec.WorkerID,
Status: domain.TaskStatus(rec.Status),
ChaptersDone: rec.ChaptersDone,
ChaptersTotal: rec.ChaptersTotal,
ErrorMessage: rec.ErrorMessage,
Started: started,
Finished: finished,
}, nil
}
// ── CoverStore ─────────────────────────────────────────────────────────────────
func (s *Store) PutCover(ctx context.Context, slug string, data []byte, contentType string) error {
@@ -1040,6 +1188,11 @@ func (s *Store) GetCover(ctx context.Context, slug string) ([]byte, string, bool
return data, ct, true, nil
}
// PutImportFile stores an uploaded import file (PDF/EPUB) in MinIO.
func (s *Store) PutImportFile(ctx context.Context, key string, data []byte) error {
return s.mc.putObject(ctx, "imports", key, "application/octet-stream", data)
}
func (s *Store) CoverExists(ctx context.Context, slug string) bool {
return s.mc.coverExists(ctx, CoverObjectKey(slug))
}

View File

@@ -33,6 +33,10 @@ type Producer interface {
// returns the assigned PocketBase record ID.
CreateTranslationTask(ctx context.Context, slug string, chapter int, lang string) (string, error)
// CreateImportTask inserts a new import task with status=pending and
// returns the assigned PocketBase record ID.
CreateImportTask(ctx context.Context, slug, title, fileType, objectKey string) (string, error)
// CancelTask transitions a pending task to status=cancelled.
// Returns ErrNotFound if the task does not exist.
CancelTask(ctx context.Context, id string) error
@@ -59,6 +63,11 @@ type Consumer interface {
// Returns (zero, false, nil) when the queue is empty.
ClaimNextTranslationTask(ctx context.Context, workerID string) (domain.TranslationTask, bool, error)
// ClaimNextImportTask atomically finds the oldest pending import task,
// sets its status=running and worker_id=workerID, and returns it.
// Returns (zero, false, nil) when the queue is empty.
ClaimNextImportTask(ctx context.Context, workerID string) (domain.ImportTask, bool, error)
// FinishScrapeTask marks a running scrape task as done and records the result.
FinishScrapeTask(ctx context.Context, id string, result domain.ScrapeResult) error
@@ -68,6 +77,9 @@ type Consumer interface {
// FinishTranslationTask marks a running translation task as done and records the result.
FinishTranslationTask(ctx context.Context, id string, result domain.TranslationResult) error
// FinishImportTask marks a running import task as done and records the result.
FinishImportTask(ctx context.Context, id string, result domain.ImportResult) error
// FailTask marks a task (scrape, audio, or translation) as failed with an error message.
FailTask(ctx context.Context, id, errMsg string) error
@@ -104,4 +116,11 @@ type Reader interface {
// GetTranslationTask returns the most recent translation task for cacheKey.
// Returns (zero, false, nil) if not found.
GetTranslationTask(ctx context.Context, cacheKey string) (domain.TranslationTask, bool, error)
// ListImportTasks returns all import tasks sorted by started descending.
ListImportTasks(ctx context.Context) ([]domain.ImportTask, error)
// GetImportTask returns a single import task by ID.
// Returns (zero, false, nil) if not found.
GetImportTask(ctx context.Context, id string) (domain.ImportTask, bool, error)
}

View File

@@ -26,6 +26,9 @@ 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) {
return "import-1", nil
}
func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil }
func (s *stubStore) CancelAudioTasksBySlug(_ context.Context, _ string) (int, error) { return 0, nil }
@@ -38,6 +41,9 @@ func (s *stubStore) ClaimNextAudioTask(_ context.Context, _ string) (domain.Audi
func (s *stubStore) ClaimNextTranslationTask(_ context.Context, _ string) (domain.TranslationTask, bool, error) {
return domain.TranslationTask{ID: "translation-1", Status: domain.TaskStatusRunning}, true, nil
}
func (s *stubStore) ClaimNextImportTask(_ context.Context, _ string) (domain.ImportTask, bool, error) {
return domain.ImportTask{ID: "import-1", Status: domain.TaskStatusRunning}, true, nil
}
func (s *stubStore) FinishScrapeTask(_ context.Context, _ string, _ domain.ScrapeResult) error {
return nil
}
@@ -47,6 +53,9 @@ func (s *stubStore) FinishAudioTask(_ context.Context, _ string, _ domain.AudioR
func (s *stubStore) FinishTranslationTask(_ context.Context, _ string, _ domain.TranslationResult) error {
return nil
}
func (s *stubStore) FinishImportTask(_ context.Context, _ string, _ domain.ImportResult) error {
return nil
}
func (s *stubStore) FailTask(_ context.Context, _, _ string) error { return nil }
func (s *stubStore) HeartbeatTask(_ context.Context, _ string) error { return nil }
@@ -69,6 +78,10 @@ func (s *stubStore) ListTranslationTasks(_ context.Context) ([]domain.Translatio
func (s *stubStore) GetTranslationTask(_ context.Context, _ string) (domain.TranslationTask, bool, error) {
return domain.TranslationTask{}, false, nil
}
func (s *stubStore) ListImportTasks(_ context.Context) ([]domain.ImportTask, error) { return nil, nil }
func (s *stubStore) GetImportTask(_ context.Context, _ string) (domain.ImportTask, bool, error) {
return domain.ImportTask{}, false, nil
}
// Verify the stub satisfies all three interfaces at compile time.
var _ taskqueue.Producer = (*stubStore)(nil)

View File

@@ -402,6 +402,7 @@
"admin_nav_scrape": "Scrape",
"admin_nav_audio": "Audio",
"admin_nav_translation": "Translation",
"admin_nav_import": "Import",
"admin_nav_changelog": "Changelog",
"admin_nav_image_gen": "Image Gen",
"admin_nav_text_gen": "Text Gen",

View File

@@ -1,6 +1,5 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_library": "Bibliothèque",
"nav_catalogue": "Catalogue",
"nav_feed": "Fil",
@@ -11,7 +10,6 @@
"nav_sign_out": "Déconnexion",
"nav_toggle_menu": "Menu",
"nav_admin_panel": "Panneau admin",
"footer_library": "Bibliothèque",
"footer_catalogue": "Catalogue",
"footer_feedback": "Retour",
@@ -20,7 +18,6 @@
"footer_dmca": "DMCA",
"footer_copyright": "© {year} libnovel",
"footer_dev": "dev",
"home_title": "libnovel",
"home_stat_books": "Livres",
"home_stat_chapters": "Chapitres",
@@ -34,7 +31,6 @@
"home_discover_novels": "Découvrir des romans",
"home_via_reader": "via {username}",
"home_chapter_badge": "ch.{n}",
"player_generating": "Génération… {percent}%",
"player_loading": "Chargement…",
"player_chapters": "Chapitres",
@@ -58,7 +54,6 @@
"player_auto_next_aria": "Suivant auto {state}",
"player_go_to_chapter": "Aller au chapitre",
"player_close": "Fermer le lecteur",
"login_page_title": "Connexion — libnovel",
"login_heading": "Se connecter à libnovel",
"login_subheading": "Choisissez un fournisseur pour continuer",
@@ -68,7 +63,6 @@
"login_error_oauth_state": "Connexion annulée ou expirée. Veuillez réessayer.",
"login_error_oauth_failed": "Impossible de se connecter au fournisseur. Veuillez réessayer.",
"login_error_oauth_no_email": "Votre compte n'a pas d'adresse e-mail vérifiée. Ajoutez-en une et réessayez.",
"books_page_title": "Bibliothèque — libnovel",
"books_heading": "Votre bibliothèque",
"books_empty_title": "Aucun livre pour l'instant",
@@ -78,7 +72,6 @@
"books_last_read": "Dernier lu : Ch.{n}",
"books_reading_progress": "Ch.{current} / {total}",
"books_remove": "Supprimer",
"catalogue_page_title": "Catalogue — libnovel",
"catalogue_heading": "Catalogue",
"catalogue_search_placeholder": "Rechercher des romans…",
@@ -99,7 +92,6 @@
"catalogue_loading": "Chargement…",
"catalogue_load_more": "Charger plus",
"catalogue_results_count": "{n} résultats",
"book_detail_page_title": "{title} — libnovel",
"book_detail_signin_to_save": "Connectez-vous pour sauvegarder",
"book_detail_add_to_library": "Ajouter à la bibliothèque",
@@ -116,13 +108,11 @@
"book_detail_rescrape": "Réextraire",
"book_detail_scraping": "Extraction en cours…",
"book_detail_in_library": "Dans la bibliothèque",
"chapters_page_title": "Chapitres — {title}",
"chapters_heading": "Chapitres",
"chapters_back_to_book": "Retour au livre",
"chapters_reading_now": "En cours de lecture",
"chapters_empty": "Aucun chapitre extrait pour l'instant.",
"reader_page_title": "{title} — Ch.{n} — libnovel",
"reader_play_narration": "Lire la narration",
"reader_generating_audio": "Génération audio…",
@@ -144,7 +134,6 @@
"reader_auto_next": "Suivant auto",
"reader_speed": "Vitesse",
"reader_preview_notice": "Aperçu — ce chapitre n'a pas été entièrement extrait.",
"profile_page_title": "Profil — libnovel",
"profile_heading": "Profil",
"profile_avatar_label": "Avatar",
@@ -179,7 +168,6 @@
"profile_sessions_heading": "Sessions actives",
"profile_sign_out_all": "Se déconnecter de tous les autres appareils",
"profile_joined": "Inscrit le {date}",
"user_page_title": "{username} — libnovel",
"user_library_heading": "Bibliothèque de {username}",
"user_follow": "Suivre",
@@ -187,13 +175,11 @@
"user_followers": "{n} abonnés",
"user_following": "{n} abonnements",
"user_library_empty": "Aucun livre dans la bibliothèque.",
"error_not_found_title": "Page introuvable",
"error_not_found_body": "La page que vous cherchez n'existe pas.",
"error_generic_title": "Une erreur s'est produite",
"error_go_home": "Accueil",
"error_status": "Erreur {status}",
"admin_scrape_page_title": "Extraction — Admin",
"admin_scrape_heading": "Extraction",
"admin_scrape_catalogue": "Extraire le catalogue",
@@ -211,14 +197,11 @@
"admin_scrape_status_cancelled": "Annulé",
"admin_tasks_heading": "Tâches récentes",
"admin_tasks_empty": "Aucune tâche pour l'instant.",
"admin_audio_page_title": "Audio — Admin",
"admin_audio_heading": "Tâches audio",
"admin_audio_empty": "Aucune tâche audio.",
"admin_changelog_page_title": "Changelog — Admin",
"admin_changelog_heading": "Changelog",
"comments_heading": "Commentaires",
"comments_empty": "Aucun commentaire pour l'instant. Soyez le premier !",
"comments_placeholder": "Écrire un commentaire…",
@@ -232,12 +215,10 @@
"comments_hide_replies": "Masquer les réponses",
"comments_edited": "modifié",
"comments_deleted": "[supprimé]",
"disclaimer_page_title": "Avertissement — libnovel",
"privacy_page_title": "Politique de confidentialité — libnovel",
"dmca_page_title": "DMCA — libnovel",
"terms_page_title": "Conditions d'utilisation — libnovel",
"common_loading": "Chargement…",
"common_error": "Erreur",
"common_save": "Enregistrer",
@@ -251,15 +232,12 @@
"common_no": "Non",
"common_on": "activé",
"common_off": "désactivé",
"locale_switcher_label": "Langue",
"books_empty_library": "Votre bibliothèque est vide.",
"books_empty_discover": "Les livres que vous commencez à lire ou enregistrez depuis",
"books_empty_discover_link": "Découvrir",
"books_empty_discover_suffix": "apparaîtront ici.",
"books_count": "{n} livre{s}",
"catalogue_sort_updated": "Mis à jour",
"catalogue_search_button": "Rechercher",
"catalogue_refresh": "Actualiser",
@@ -292,7 +270,6 @@
"catalogue_scrape_forbidden_badge": "Interdit",
"catalogue_scrape_novel_button": "Extraire",
"catalogue_scraping_novel": "Extraction…",
"book_detail_not_in_library": "pas dans la bibliothèque",
"book_detail_continue_ch": "Continuer ch.{n}",
"book_detail_start_ch1": "Commencer au ch.1",
@@ -328,18 +305,15 @@
"book_detail_rescrape_book": "Réextraire le livre",
"book_detail_less": "Moins",
"book_detail_more": "Plus",
"chapters_search_placeholder": "Rechercher des chapitres…",
"chapters_jump_to": "Aller au Ch.{n}",
"chapters_no_match": "Aucun chapitre ne correspond à « {q} »",
"chapters_none_available": "Aucun chapitre disponible pour l'instant.",
"chapters_reading_indicator": "en cours",
"chapters_result_count": "{n} résultats",
"reader_fetching_chapter": "Récupération du chapitre…",
"reader_words": "{n} mots",
"reader_preview_audio_notice": "Aperçu — audio non disponible pour les livres hors bibliothèque.",
"profile_click_to_change": "Cliquez sur l'avatar pour changer la photo",
"profile_tts_voice": "Voix TTS",
"profile_auto_advance": "Avancer automatiquement au chapitre suivant",
@@ -357,7 +331,6 @@
"profile_updating": "Mise à jour…",
"profile_password_changed_ok": "Mot de passe modifié avec succès.",
"profile_playback_speed": "Vitesse de lecture — {speed}x",
"profile_subscription_heading": "Abonnement",
"profile_plan_pro": "Pro",
"profile_plan_free": "Gratuit",
@@ -369,7 +342,7 @@
"profile_upgrade_monthly": "Mensuel — 6 $ / mois",
"profile_upgrade_annual": "Annuel — 48 $ / an",
"profile_free_limits": "Plan gratuit : 3 chapitres audio par jour, lecture en anglais uniquement.",
"subscribe_page_title": "Passer Pro \u2014 libnovel",
"subscribe_page_title": "Passer Pro libnovel",
"subscribe_heading": "Lisez plus. Écoutez plus.",
"subscribe_subheading": "Passez Pro et débloquez l'expérience libnovel complète.",
"subscribe_monthly_label": "Mensuel",
@@ -389,14 +362,12 @@
"subscribe_benefit_downloads": "Télécharger des chapitres pour une écoute hors ligne",
"subscribe_login_prompt": "Connectez-vous pour vous abonner",
"subscribe_login_cta": "Se connecter",
"user_currently_reading": "En cours de lecture",
"user_library_count": "Bibliothèque ({n})",
"user_joined": "Inscrit le {date}",
"user_followers_label": "abonnés",
"user_following_label": "abonnements",
"user_no_books": "Aucun livre dans la bibliothèque pour l'instant.",
"admin_pages_label": "Pages",
"admin_tools_label": "Outils",
"admin_nav_scrape": "Scrape",
@@ -412,7 +383,6 @@
"admin_nav_logs": "Journaux",
"admin_nav_uptime": "Disponibilité",
"admin_nav_push": "Notifications",
"admin_scrape_status_idle": "Inactif",
"admin_scrape_full_catalogue": "Catalogue complet",
"admin_scrape_single_book": "Livre unique",
@@ -423,25 +393,21 @@
"admin_scrape_start": "Démarrer l'extraction",
"admin_scrape_queuing": "En file d'attente…",
"admin_scrape_running": "En cours…",
"admin_audio_filter_jobs": "Filtrer par slug, voix ou statut…",
"admin_audio_filter_cache": "Filtrer par slug, chapitre ou voix…",
"admin_audio_no_matching_jobs": "Aucun job correspondant.",
"admin_audio_no_jobs": "Aucun job audio pour l'instant.",
"admin_audio_cache_empty": "Cache audio vide.",
"admin_audio_no_cache_results": "Aucun résultat.",
"admin_changelog_gitea": "Releases Gitea",
"admin_changelog_no_releases": "Aucune release trouvée.",
"admin_changelog_load_error": "Impossible de charger les releases : {error}",
"comments_top": "Les meilleures",
"comments_new": "Nouvelles",
"comments_posting": "Publication…",
"comments_login_link": "Connectez-vous",
"comments_login_suffix": "pour laisser un commentaire.",
"comments_anonymous": "Anonyme",
"reader_audio_narration": "Narration Audio",
"reader_playing": "Lecture en cours — contrôles ci-dessous",
"reader_paused": "En pause — contrôles ci-dessous",
@@ -454,7 +420,6 @@
"reader_voice_applies_next": "La nouvelle voix s'appliquera au prochain « Lire la narration ».",
"reader_choose_voice": "Choisir une voix",
"reader_generating_narration": "Génération de la narration…",
"profile_font_family": "Police",
"profile_font_system": "Système",
"profile_font_serif": "Serif",
@@ -464,7 +429,6 @@
"profile_text_size_md": "Normal",
"profile_text_size_lg": "Grand",
"profile_text_size_xl": "Très grand",
"feed_page_title": "Fil — LibNovel",
"feed_heading": "Fil d'abonnements",
"feed_subheading": "Livres lus par vos abonnements",
@@ -477,19 +441,17 @@
"feed_find_users_cta": "Trouver des lecteurs",
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana",
"admin_translation_page_title": "Translation \u2014 Admin",
"admin_translation_page_title": "Translation — Admin",
"admin_translation_heading": "Machine Translation",
"admin_translation_tab_enqueue": "Enqueue",
"admin_translation_tab_jobs": "Jobs",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status",
"admin_translation_no_matching": "No matching jobs.",
"admin_translation_no_jobs": "No translation jobs yet.",
"admin_ai_jobs_page_title": "AI Jobs \u2014 Admin",
"admin_ai_jobs_page_title": "AI Jobs — Admin",
"admin_ai_jobs_heading": "AI Jobs",
"admin_ai_jobs_subheading": "Background AI generation tasks",
"admin_text_gen_page_title": "Text Gen \u2014 Admin",
"admin_text_gen_heading": "Text Generation"
"admin_text_gen_page_title": "Text Gen — Admin",
"admin_text_gen_heading": "Text Generation",
"admin_nav_import": "Import"
}

View File

@@ -1,6 +1,5 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_library": "Perpustakaan",
"nav_catalogue": "Katalog",
"nav_feed": "Umpan",
@@ -11,7 +10,6 @@
"nav_sign_out": "Keluar",
"nav_toggle_menu": "Menu",
"nav_admin_panel": "Panel admin",
"footer_library": "Perpustakaan",
"footer_catalogue": "Katalog",
"footer_feedback": "Masukan",
@@ -20,7 +18,6 @@
"footer_dmca": "DMCA",
"footer_copyright": "© {year} libnovel",
"footer_dev": "dev",
"home_title": "libnovel",
"home_stat_books": "Buku",
"home_stat_chapters": "Bab",
@@ -34,7 +31,6 @@
"home_discover_novels": "Temukan Novel",
"home_via_reader": "via {username}",
"home_chapter_badge": "bab.{n}",
"player_generating": "Membuat… {percent}%",
"player_loading": "Memuat…",
"player_chapters": "Bab",
@@ -58,7 +54,6 @@
"player_auto_next_aria": "Auto-lanjut {state}",
"player_go_to_chapter": "Pergi ke bab",
"player_close": "Tutup pemutar",
"login_page_title": "Masuk — libnovel",
"login_heading": "Masuk ke libnovel",
"login_subheading": "Pilih penyedia untuk melanjutkan",
@@ -68,7 +63,6 @@
"login_error_oauth_state": "Masuk dibatalkan atau kedaluwarsa. Coba lagi.",
"login_error_oauth_failed": "Tidak dapat terhubung ke penyedia. Coba lagi.",
"login_error_oauth_no_email": "Akunmu tidak memiliki alamat email terverifikasi. Tambahkan dan coba lagi.",
"books_page_title": "Perpustakaan — libnovel",
"books_heading": "Perpustakaanmu",
"books_empty_title": "Belum ada buku",
@@ -78,7 +72,6 @@
"books_last_read": "Terakhir: Bab.{n}",
"books_reading_progress": "Bab.{current} / {total}",
"books_remove": "Hapus",
"catalogue_page_title": "Katalog — libnovel",
"catalogue_heading": "Katalog",
"catalogue_search_placeholder": "Cari novel…",
@@ -99,7 +92,6 @@
"catalogue_loading": "Memuat…",
"catalogue_load_more": "Muat lebih banyak",
"catalogue_results_count": "{n} hasil",
"book_detail_page_title": "{title} — libnovel",
"book_detail_signin_to_save": "Masuk untuk menyimpan",
"book_detail_add_to_library": "Tambah ke Perpustakaan",
@@ -116,13 +108,11 @@
"book_detail_rescrape": "Perbarui",
"book_detail_scraping": "Memperbarui…",
"book_detail_in_library": "Ada di Perpustakaan",
"chapters_page_title": "Bab — {title}",
"chapters_heading": "Bab",
"chapters_back_to_book": "Kembali ke buku",
"chapters_reading_now": "Sedang dibaca",
"chapters_empty": "Belum ada bab yang diambil.",
"reader_page_title": "{title} — Bab.{n} — libnovel",
"reader_play_narration": "Putar narasi",
"reader_generating_audio": "Membuat audio…",
@@ -144,7 +134,6 @@
"reader_auto_next": "Auto-lanjut",
"reader_speed": "Kecepatan",
"reader_preview_notice": "Pratinjau — bab ini belum sepenuhnya diambil.",
"profile_page_title": "Profil — libnovel",
"profile_heading": "Profil",
"profile_avatar_label": "Avatar",
@@ -179,7 +168,6 @@
"profile_sessions_heading": "Sesi aktif",
"profile_sign_out_all": "Keluar dari semua perangkat lain",
"profile_joined": "Bergabung {date}",
"user_page_title": "{username} — libnovel",
"user_library_heading": "Perpustakaan {username}",
"user_follow": "Ikuti",
@@ -187,13 +175,11 @@
"user_followers": "{n} pengikut",
"user_following": "{n} mengikuti",
"user_library_empty": "Tidak ada buku di perpustakaan.",
"error_not_found_title": "Halaman tidak ditemukan",
"error_not_found_body": "Halaman yang kamu cari tidak ada.",
"error_generic_title": "Terjadi kesalahan",
"error_go_home": "Ke beranda",
"error_status": "Error {status}",
"admin_scrape_page_title": "Scrape — Admin",
"admin_scrape_heading": "Scrape",
"admin_scrape_catalogue": "Scrape Katalog",
@@ -211,14 +197,11 @@
"admin_scrape_status_cancelled": "Dibatalkan",
"admin_tasks_heading": "Tugas terbaru",
"admin_tasks_empty": "Belum ada tugas.",
"admin_audio_page_title": "Audio — Admin",
"admin_audio_heading": "Tugas Audio",
"admin_audio_empty": "Tidak ada tugas audio.",
"admin_changelog_page_title": "Changelog — Admin",
"admin_changelog_heading": "Changelog",
"comments_heading": "Komentar",
"comments_empty": "Belum ada komentar. Jadilah yang pertama!",
"comments_placeholder": "Tulis komentar…",
@@ -232,12 +215,10 @@
"comments_hide_replies": "Sembunyikan balasan",
"comments_edited": "diedit",
"comments_deleted": "[dihapus]",
"disclaimer_page_title": "Penyangkalan — libnovel",
"privacy_page_title": "Kebijakan Privasi — libnovel",
"dmca_page_title": "DMCA — libnovel",
"terms_page_title": "Syarat Layanan — libnovel",
"common_loading": "Memuat…",
"common_error": "Error",
"common_save": "Simpan",
@@ -251,15 +232,12 @@
"common_no": "Tidak",
"common_on": "aktif",
"common_off": "nonaktif",
"locale_switcher_label": "Bahasa",
"books_empty_library": "Perpustakaanmu kosong.",
"books_empty_discover": "Buku yang mulai kamu baca atau simpan dari",
"books_empty_discover_link": "Temukan",
"books_empty_discover_suffix": "akan muncul di sini.",
"books_count": "{n} buku",
"catalogue_sort_updated": "Diperbarui",
"catalogue_search_button": "Cari",
"catalogue_refresh": "Segarkan",
@@ -292,7 +270,6 @@
"catalogue_scrape_forbidden_badge": "Terlarang",
"catalogue_scrape_novel_button": "Scrape",
"catalogue_scraping_novel": "Scraping…",
"book_detail_not_in_library": "tidak di perpustakaan",
"book_detail_continue_ch": "Lanjutkan bab.{n}",
"book_detail_start_ch1": "Mulai dari bab.1",
@@ -328,18 +305,15 @@
"book_detail_rescrape_book": "Scrape ulang buku",
"book_detail_less": "Lebih sedikit",
"book_detail_more": "Selengkapnya",
"chapters_search_placeholder": "Cari bab…",
"chapters_jump_to": "Loncat ke Bab.{n}",
"chapters_no_match": "Tidak ada bab yang cocok dengan \"{q}\"",
"chapters_none_available": "Belum ada bab tersedia.",
"chapters_reading_indicator": "sedang dibaca",
"chapters_result_count": "{n} hasil",
"reader_fetching_chapter": "Mengambil bab…",
"reader_words": "{n} kata",
"reader_preview_audio_notice": "Pratinjau — audio tidak tersedia untuk buku di luar perpustakaan.",
"profile_click_to_change": "Klik avatar untuk mengganti foto",
"profile_tts_voice": "Suara TTS",
"profile_auto_advance": "Otomatis lanjut ke bab berikutnya",
@@ -357,7 +331,6 @@
"profile_updating": "Memperbarui…",
"profile_password_changed_ok": "Kata sandi berhasil diubah.",
"profile_playback_speed": "Kecepatan pemutaran — {speed}x",
"profile_subscription_heading": "Langganan",
"profile_plan_pro": "Pro",
"profile_plan_free": "Gratis",
@@ -369,7 +342,7 @@
"profile_upgrade_monthly": "Bulanan — $6 / bln",
"profile_upgrade_annual": "Tahunan — $48 / thn",
"profile_free_limits": "Paket gratis: 3 bab audio per hari, hanya bahasa Inggris.",
"subscribe_page_title": "Jadi Pro \u2014 libnovel",
"subscribe_page_title": "Jadi Pro libnovel",
"subscribe_heading": "Baca lebih. Dengarkan lebih.",
"subscribe_subheading": "Tingkatkan ke Pro dan buka pengalaman libnovel sepenuhnya.",
"subscribe_monthly_label": "Bulanan",
@@ -389,14 +362,12 @@
"subscribe_benefit_downloads": "Unduh bab untuk didengarkan secara offline",
"subscribe_login_prompt": "Masuk untuk berlangganan",
"subscribe_login_cta": "Masuk",
"user_currently_reading": "Sedang Dibaca",
"user_library_count": "Perpustakaan ({n})",
"user_joined": "Bergabung {date}",
"user_followers_label": "pengikut",
"user_following_label": "mengikuti",
"user_no_books": "Belum ada buku di perpustakaan.",
"admin_pages_label": "Halaman",
"admin_tools_label": "Alat",
"admin_nav_scrape": "Scrape",
@@ -412,7 +383,6 @@
"admin_nav_logs": "Log",
"admin_nav_uptime": "Uptime",
"admin_nav_push": "Notifikasi",
"admin_scrape_status_idle": "Menunggu",
"admin_scrape_full_catalogue": "Katalog penuh",
"admin_scrape_single_book": "Satu buku",
@@ -423,25 +393,21 @@
"admin_scrape_start": "Mulai scrape",
"admin_scrape_queuing": "Mengantri…",
"admin_scrape_running": "Berjalan…",
"admin_audio_filter_jobs": "Filter berdasarkan slug, suara, atau status…",
"admin_audio_filter_cache": "Filter berdasarkan slug, bab, atau suara…",
"admin_audio_no_matching_jobs": "Tidak ada pekerjaan yang cocok.",
"admin_audio_no_jobs": "Belum ada pekerjaan audio.",
"admin_audio_cache_empty": "Cache audio kosong.",
"admin_audio_no_cache_results": "Tidak ada hasil.",
"admin_changelog_gitea": "Rilis Gitea",
"admin_changelog_no_releases": "Tidak ada rilis.",
"admin_changelog_load_error": "Gagal memuat rilis: {error}",
"comments_top": "Teratas",
"comments_new": "Terbaru",
"comments_posting": "Mengirim…",
"comments_login_link": "Masuk",
"comments_login_suffix": "untuk meninggalkan komentar.",
"comments_anonymous": "Anonim",
"reader_audio_narration": "Narasi Audio",
"reader_playing": "Memutar — kontrol di bawah",
"reader_paused": "Dijeda — kontrol di bawah",
@@ -454,7 +420,6 @@
"reader_voice_applies_next": "Suara baru berlaku pada \"Putar narasi\" berikutnya.",
"reader_choose_voice": "Pilih Suara",
"reader_generating_narration": "Membuat narasi…",
"profile_font_family": "Jenis Font",
"profile_font_system": "Sistem",
"profile_font_serif": "Serif",
@@ -464,7 +429,6 @@
"profile_text_size_md": "Normal",
"profile_text_size_lg": "Besar",
"profile_text_size_xl": "Sangat Besar",
"feed_page_title": "Umpan — LibNovel",
"feed_heading": "Umpan Ikutan",
"feed_subheading": "Buku yang sedang dibaca oleh pengguna yang Anda ikuti",
@@ -477,19 +441,17 @@
"feed_find_users_cta": "Temukan pembaca",
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana",
"admin_translation_page_title": "Translation \u2014 Admin",
"admin_translation_page_title": "Translation — Admin",
"admin_translation_heading": "Machine Translation",
"admin_translation_tab_enqueue": "Enqueue",
"admin_translation_tab_jobs": "Jobs",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status",
"admin_translation_no_matching": "No matching jobs.",
"admin_translation_no_jobs": "No translation jobs yet.",
"admin_ai_jobs_page_title": "AI Jobs \u2014 Admin",
"admin_ai_jobs_page_title": "AI Jobs — Admin",
"admin_ai_jobs_heading": "AI Jobs",
"admin_ai_jobs_subheading": "Background AI generation tasks",
"admin_text_gen_page_title": "Text Gen \u2014 Admin",
"admin_text_gen_heading": "Text Generation"
"admin_text_gen_page_title": "Text Gen — Admin",
"admin_text_gen_heading": "Text Generation",
"admin_nav_import": "Import"
}

View File

@@ -1,6 +1,5 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_library": "Biblioteca",
"nav_catalogue": "Catálogo",
"nav_feed": "Feed",
@@ -11,7 +10,6 @@
"nav_sign_out": "Sair",
"nav_toggle_menu": "Menu",
"nav_admin_panel": "Painel admin",
"footer_library": "Biblioteca",
"footer_catalogue": "Catálogo",
"footer_feedback": "Feedback",
@@ -20,7 +18,6 @@
"footer_dmca": "DMCA",
"footer_copyright": "© {year} libnovel",
"footer_dev": "dev",
"home_title": "libnovel",
"home_stat_books": "Livros",
"home_stat_chapters": "Capítulos",
@@ -34,7 +31,6 @@
"home_discover_novels": "Descobrir Romances",
"home_via_reader": "via {username}",
"home_chapter_badge": "cap.{n}",
"player_generating": "Gerando… {percent}%",
"player_loading": "Carregando…",
"player_chapters": "Capítulos",
@@ -58,7 +54,6 @@
"player_auto_next_aria": "Próximo automático {state}",
"player_go_to_chapter": "Ir para capítulo",
"player_close": "Fechar player",
"login_page_title": "Entrar — libnovel",
"login_heading": "Entrar no libnovel",
"login_subheading": "Escolha um provedor para continuar",
@@ -68,7 +63,6 @@
"login_error_oauth_state": "Login cancelado ou expirado. Tente novamente.",
"login_error_oauth_failed": "Não foi possível conectar ao provedor. Tente novamente.",
"login_error_oauth_no_email": "Sua conta não tem endereço de email verificado. Adicione um e tente novamente.",
"books_page_title": "Biblioteca — libnovel",
"books_heading": "Sua Biblioteca",
"books_empty_title": "Nenhum livro ainda",
@@ -78,7 +72,6 @@
"books_last_read": "Último: Cap.{n}",
"books_reading_progress": "Cap.{current} / {total}",
"books_remove": "Remover",
"catalogue_page_title": "Catálogo — libnovel",
"catalogue_heading": "Catálogo",
"catalogue_search_placeholder": "Pesquisar romances…",
@@ -99,7 +92,6 @@
"catalogue_loading": "Carregando…",
"catalogue_load_more": "Carregar mais",
"catalogue_results_count": "{n} resultados",
"book_detail_page_title": "{title} — libnovel",
"book_detail_signin_to_save": "Entre para salvar",
"book_detail_add_to_library": "Adicionar à Biblioteca",
@@ -116,13 +108,11 @@
"book_detail_rescrape": "Atualizar",
"book_detail_scraping": "Atualizando…",
"book_detail_in_library": "Na Biblioteca",
"chapters_page_title": "Capítulos — {title}",
"chapters_heading": "Capítulos",
"chapters_back_to_book": "Voltar ao livro",
"chapters_reading_now": "Lendo",
"chapters_empty": "Nenhum capítulo extraído ainda.",
"reader_page_title": "{title} — Cap.{n} — libnovel",
"reader_play_narration": "Reproduzir narração",
"reader_generating_audio": "Gerando áudio…",
@@ -144,7 +134,6 @@
"reader_auto_next": "Próximo automático",
"reader_speed": "Velocidade",
"reader_preview_notice": "Prévia — este capítulo não foi totalmente extraído.",
"profile_page_title": "Perfil — libnovel",
"profile_heading": "Perfil",
"profile_avatar_label": "Avatar",
@@ -179,7 +168,6 @@
"profile_sessions_heading": "Sessões ativas",
"profile_sign_out_all": "Sair de todos os outros dispositivos",
"profile_joined": "Entrou em {date}",
"user_page_title": "{username} — libnovel",
"user_library_heading": "Biblioteca de {username}",
"user_follow": "Seguir",
@@ -187,13 +175,11 @@
"user_followers": "{n} seguidores",
"user_following": "{n} seguindo",
"user_library_empty": "Nenhum livro na biblioteca.",
"error_not_found_title": "Página não encontrada",
"error_not_found_body": "A página que você procura não existe.",
"error_generic_title": "Algo deu errado",
"error_go_home": "Ir para início",
"error_status": "Erro {status}",
"admin_scrape_page_title": "Extração — Admin",
"admin_scrape_heading": "Extração",
"admin_scrape_catalogue": "Extrair Catálogo",
@@ -211,14 +197,11 @@
"admin_scrape_status_cancelled": "Cancelado",
"admin_tasks_heading": "Tarefas recentes",
"admin_tasks_empty": "Nenhuma tarefa ainda.",
"admin_audio_page_title": "Áudio — Admin",
"admin_audio_heading": "Tarefas de Áudio",
"admin_audio_empty": "Nenhuma tarefa de áudio.",
"admin_changelog_page_title": "Changelog — Admin",
"admin_changelog_heading": "Changelog",
"comments_heading": "Comentários",
"comments_empty": "Nenhum comentário ainda. Seja o primeiro!",
"comments_placeholder": "Escreva um comentário…",
@@ -232,12 +215,10 @@
"comments_hide_replies": "Ocultar respostas",
"comments_edited": "editado",
"comments_deleted": "[excluído]",
"disclaimer_page_title": "Aviso Legal — libnovel",
"privacy_page_title": "Política de Privacidade — libnovel",
"dmca_page_title": "DMCA — libnovel",
"terms_page_title": "Termos de Serviço — libnovel",
"common_loading": "Carregando…",
"common_error": "Erro",
"common_save": "Salvar",
@@ -251,15 +232,12 @@
"common_no": "Não",
"common_on": "ativado",
"common_off": "desativado",
"locale_switcher_label": "Idioma",
"books_empty_library": "Sua biblioteca está vazia.",
"books_empty_discover": "Livros que você começar a ler ou salvar de",
"books_empty_discover_link": "Descobrir",
"books_empty_discover_suffix": "aparecerão aqui.",
"books_count": "{n} livro{s}",
"catalogue_sort_updated": "Atualizado",
"catalogue_search_button": "Pesquisar",
"catalogue_refresh": "Atualizar",
@@ -292,7 +270,6 @@
"catalogue_scrape_forbidden_badge": "Proibido",
"catalogue_scrape_novel_button": "Extrair",
"catalogue_scraping_novel": "Extraindo…",
"book_detail_not_in_library": "não está na biblioteca",
"book_detail_continue_ch": "Continuar cap.{n}",
"book_detail_start_ch1": "Começar pelo cap.1",
@@ -328,18 +305,15 @@
"book_detail_rescrape_book": "Reextrair livro",
"book_detail_less": "Menos",
"book_detail_more": "Mais",
"chapters_search_placeholder": "Pesquisar capítulos…",
"chapters_jump_to": "Ir para Cap.{n}",
"chapters_no_match": "Nenhum capítulo encontrado para \"{q}\"",
"chapters_none_available": "Nenhum capítulo disponível ainda.",
"chapters_reading_indicator": "lendo",
"chapters_result_count": "{n} resultados",
"reader_fetching_chapter": "Buscando capítulo…",
"reader_words": "{n} palavras",
"reader_preview_audio_notice": "Prévia — áudio não disponível para livros fora da biblioteca.",
"profile_click_to_change": "Clique no avatar para mudar a foto",
"profile_tts_voice": "Voz TTS",
"profile_auto_advance": "Avançar automaticamente para o próximo capítulo",
@@ -357,7 +331,6 @@
"profile_updating": "Atualizando…",
"profile_password_changed_ok": "Senha alterada com sucesso.",
"profile_playback_speed": "Velocidade de reprodução — {speed}x",
"profile_subscription_heading": "Assinatura",
"profile_plan_pro": "Pro",
"profile_plan_free": "Gratuito",
@@ -369,7 +342,7 @@
"profile_upgrade_monthly": "Mensal — $6 / mês",
"profile_upgrade_annual": "Anual — $48 / ano",
"profile_free_limits": "Plano gratuito: 3 capítulos de áudio por dia, somente inglês.",
"subscribe_page_title": "Seja Pro \u2014 libnovel",
"subscribe_page_title": "Seja Pro libnovel",
"subscribe_heading": "Leia mais. Ouça mais.",
"subscribe_subheading": "Torne-se Pro e desbloqueie a experiência completa do libnovel.",
"subscribe_monthly_label": "Mensal",
@@ -389,14 +362,12 @@
"subscribe_benefit_downloads": "Baixe capítulos para ouvir offline",
"subscribe_login_prompt": "Entre para assinar",
"subscribe_login_cta": "Entrar",
"user_currently_reading": "Lendo Agora",
"user_library_count": "Biblioteca ({n})",
"user_joined": "Entrou em {date}",
"user_followers_label": "seguidores",
"user_following_label": "seguindo",
"user_no_books": "Nenhum livro na biblioteca ainda.",
"admin_pages_label": "Páginas",
"admin_tools_label": "Ferramentas",
"admin_nav_scrape": "Scrape",
@@ -412,7 +383,6 @@
"admin_nav_logs": "Logs",
"admin_nav_uptime": "Uptime",
"admin_nav_push": "Notificações",
"admin_scrape_status_idle": "Ocioso",
"admin_scrape_full_catalogue": "Catálogo completo",
"admin_scrape_single_book": "Livro único",
@@ -423,25 +393,21 @@
"admin_scrape_start": "Iniciar extração",
"admin_scrape_queuing": "Na fila…",
"admin_scrape_running": "Executando…",
"admin_audio_filter_jobs": "Filtrar por slug, voz ou status…",
"admin_audio_filter_cache": "Filtrar por slug, capítulo ou voz…",
"admin_audio_no_matching_jobs": "Nenhum job correspondente.",
"admin_audio_no_jobs": "Nenhum job de áudio ainda.",
"admin_audio_cache_empty": "Cache de áudio vazio.",
"admin_audio_no_cache_results": "Sem resultados.",
"admin_changelog_gitea": "Releases do Gitea",
"admin_changelog_no_releases": "Nenhum release encontrado.",
"admin_changelog_load_error": "Não foi possível carregar os releases: {error}",
"comments_top": "Mais votados",
"comments_new": "Novos",
"comments_posting": "Publicando…",
"comments_login_link": "Entre",
"comments_login_suffix": "para deixar um comentário.",
"comments_anonymous": "Anônimo",
"reader_audio_narration": "Narração em Áudio",
"reader_playing": "Reproduzindo — controles abaixo",
"reader_paused": "Pausado — controles abaixo",
@@ -454,7 +420,6 @@
"reader_voice_applies_next": "A nova voz será aplicada no próximo \"Reproduzir narração\".",
"reader_choose_voice": "Escolher Voz",
"reader_generating_narration": "Gerando narração…",
"profile_font_family": "Fonte",
"profile_font_system": "Sistema",
"profile_font_serif": "Serif",
@@ -464,7 +429,6 @@
"profile_text_size_md": "Normal",
"profile_text_size_lg": "Grande",
"profile_text_size_xl": "Muito grande",
"feed_page_title": "Feed — LibNovel",
"feed_heading": "Feed de seguidos",
"feed_subheading": "Livros que seus seguidos estão lendo",
@@ -477,19 +441,17 @@
"feed_find_users_cta": "Encontrar leitores",
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana",
"admin_translation_page_title": "Translation \u2014 Admin",
"admin_translation_page_title": "Translation — Admin",
"admin_translation_heading": "Machine Translation",
"admin_translation_tab_enqueue": "Enqueue",
"admin_translation_tab_jobs": "Jobs",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status",
"admin_translation_no_matching": "No matching jobs.",
"admin_translation_no_jobs": "No translation jobs yet.",
"admin_ai_jobs_page_title": "AI Jobs \u2014 Admin",
"admin_ai_jobs_page_title": "AI Jobs — Admin",
"admin_ai_jobs_heading": "AI Jobs",
"admin_ai_jobs_subheading": "Background AI generation tasks",
"admin_text_gen_page_title": "Text Gen \u2014 Admin",
"admin_text_gen_heading": "Text Generation"
"admin_text_gen_page_title": "Text Gen — Admin",
"admin_text_gen_heading": "Text Generation",
"admin_nav_import": "Import"
}

View File

@@ -1,6 +1,5 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_library": "Библиотека",
"nav_catalogue": "Каталог",
"nav_feed": "Лента",
@@ -11,7 +10,6 @@
"nav_sign_out": "Выйти",
"nav_toggle_menu": "Меню",
"nav_admin_panel": "Панель администратора",
"footer_library": "Библиотека",
"footer_catalogue": "Каталог",
"footer_feedback": "Обратная связь",
@@ -20,7 +18,6 @@
"footer_dmca": "DMCA",
"footer_copyright": "© {year} libnovel",
"footer_dev": "dev",
"home_title": "libnovel",
"home_stat_books": "Книги",
"home_stat_chapters": "Главы",
@@ -34,7 +31,6 @@
"home_discover_novels": "Открыть новеллы",
"home_via_reader": "от {username}",
"home_chapter_badge": "гл.{n}",
"player_generating": "Генерация… {percent}%",
"player_loading": "Загрузка…",
"player_chapters": "Главы",
@@ -58,7 +54,6 @@
"player_auto_next_aria": "Автопереход {state}",
"player_go_to_chapter": "Перейти к главе",
"player_close": "Закрыть плеер",
"login_page_title": "Вход — libnovel",
"login_heading": "Войти в libnovel",
"login_subheading": "Выберите провайдера для входа",
@@ -68,7 +63,6 @@
"login_error_oauth_state": "Вход отменён или истёк срок действия. Попробуйте снова.",
"login_error_oauth_failed": "Не удалось подключиться к провайдеру. Попробуйте снова.",
"login_error_oauth_no_email": "У вашего аккаунта нет подтверждённого email. Добавьте его и повторите попытку.",
"books_page_title": "Библиотека — libnovel",
"books_heading": "Ваша библиотека",
"books_empty_title": "Книг пока нет",
@@ -78,7 +72,6 @@
"books_last_read": "Последнее: гл.{n}",
"books_reading_progress": "Гл.{current} / {total}",
"books_remove": "Удалить",
"catalogue_page_title": "Каталог — libnovel",
"catalogue_heading": "Каталог",
"catalogue_search_placeholder": "Поиск новелл…",
@@ -99,7 +92,6 @@
"catalogue_loading": "Загрузка…",
"catalogue_load_more": "Загрузить ещё",
"catalogue_results_count": "{n} результатов",
"book_detail_page_title": "{title} — libnovel",
"book_detail_signin_to_save": "Войдите, чтобы сохранить",
"book_detail_add_to_library": "В библиотеку",
@@ -116,13 +108,11 @@
"book_detail_rescrape": "Обновить",
"book_detail_scraping": "Обновление…",
"book_detail_in_library": "В библиотеке",
"chapters_page_title": "Главы — {title}",
"chapters_heading": "Главы",
"chapters_back_to_book": "К книге",
"chapters_reading_now": "Читается",
"chapters_empty": "Главы ещё не загружены.",
"reader_page_title": "{title} — Гл.{n} — libnovel",
"reader_play_narration": "Воспроизвести озвучку",
"reader_generating_audio": "Генерация аудио…",
@@ -144,7 +134,6 @@
"reader_auto_next": "Автопереход",
"reader_speed": "Скорость",
"reader_preview_notice": "Предпросмотр — эта глава не полностью загружена.",
"profile_page_title": "Профиль — libnovel",
"profile_heading": "Профиль",
"profile_avatar_label": "Аватар",
@@ -179,7 +168,6 @@
"profile_sessions_heading": "Активные сессии",
"profile_sign_out_all": "Выйти на всех других устройствах",
"profile_joined": "Зарегистрирован {date}",
"user_page_title": "{username} — libnovel",
"user_library_heading": "Библиотека {username}",
"user_follow": "Подписаться",
@@ -187,13 +175,11 @@
"user_followers": "{n} подписчиков",
"user_following": "{n} подписок",
"user_library_empty": "В библиотеке нет книг.",
"error_not_found_title": "Страница не найдена",
"error_not_found_body": "Запрошенная страница не существует.",
"error_generic_title": "Что-то пошло не так",
"error_go_home": "На главную",
"error_status": "Ошибка {status}",
"admin_scrape_page_title": "Парсинг — Админ",
"admin_scrape_heading": "Парсинг",
"admin_scrape_catalogue": "Парсинг каталога",
@@ -211,14 +197,11 @@
"admin_scrape_status_cancelled": "Отменено",
"admin_tasks_heading": "Последние задачи",
"admin_tasks_empty": "Задач пока нет.",
"admin_audio_page_title": "Аудио — Админ",
"admin_audio_heading": "Аудио задачи",
"admin_audio_empty": "Аудио задач нет.",
"admin_changelog_page_title": "Changelog — Админ",
"admin_changelog_heading": "Changelog",
"comments_heading": "Комментарии",
"comments_empty": "Комментариев пока нет. Будьте первым!",
"comments_placeholder": "Написать комментарий…",
@@ -232,12 +215,10 @@
"comments_hide_replies": "Скрыть ответы",
"comments_edited": "изменено",
"comments_deleted": "[удалено]",
"disclaimer_page_title": "Отказ от ответственности — libnovel",
"privacy_page_title": "Политика конфиденциальности — libnovel",
"dmca_page_title": "DMCA — libnovel",
"terms_page_title": "Условия использования — libnovel",
"common_loading": "Загрузка…",
"common_error": "Ошибка",
"common_save": "Сохранить",
@@ -251,15 +232,12 @@
"common_no": "Нет",
"common_on": "вкл.",
"common_off": "выкл.",
"locale_switcher_label": "Язык",
"books_empty_library": "Ваша библиотека пуста.",
"books_empty_discover": "Книги, которые вы начнёте читать или сохраните из",
"books_empty_discover_link": "Каталога",
"books_empty_discover_suffix": "появятся здесь.",
"books_count": "{n} книг{s}",
"catalogue_sort_updated": "По дате обновления",
"catalogue_search_button": "Поиск",
"catalogue_refresh": "Обновить",
@@ -292,7 +270,6 @@
"catalogue_scrape_forbidden_badge": "Запрещено",
"catalogue_scrape_novel_button": "Парсить",
"catalogue_scraping_novel": "Парсинг…",
"book_detail_not_in_library": "не в библиотеке",
"book_detail_continue_ch": "Продолжить гл.{n}",
"book_detail_start_ch1": "Начать с гл.1",
@@ -328,18 +305,15 @@
"book_detail_rescrape_book": "Перепарсить книгу",
"book_detail_less": "Скрыть",
"book_detail_more": "Ещё",
"chapters_search_placeholder": "Поиск глав…",
"chapters_jump_to": "Перейти к гл.{n}",
"chapters_no_match": "Главы по запросу «{q}» не найдены",
"chapters_none_available": "Глав пока нет.",
"chapters_reading_indicator": "читается",
"chapters_result_count": "{n} результатов",
"reader_fetching_chapter": "Загрузка главы…",
"reader_words": "{n} слов",
"reader_preview_audio_notice": "Предпросмотр — аудио недоступно для книг вне библиотеки.",
"profile_click_to_change": "Нажмите на аватар для смены фото",
"profile_tts_voice": "Голос TTS",
"profile_auto_advance": "Автопереход к следующей главе",
@@ -357,7 +331,6 @@
"profile_updating": "Обновление…",
"profile_password_changed_ok": "Пароль успешно изменён.",
"profile_playback_speed": "Скорость воспроизведения — {speed}x",
"profile_subscription_heading": "Подписка",
"profile_plan_pro": "Pro",
"profile_plan_free": "Бесплатно",
@@ -369,7 +342,7 @@
"profile_upgrade_monthly": "Ежемесячно — $6 / мес",
"profile_upgrade_annual": "Ежегодно — $48 / год",
"profile_free_limits": "Бесплатный план: 3 аудиоглавы в день, только английский.",
"subscribe_page_title": "Перейти на Pro \u2014 libnovel",
"subscribe_page_title": "Перейти на Pro libnovel",
"subscribe_heading": "Читайте больше. Слушайте больше.",
"subscribe_subheading": "Перейдите на Pro и откройте полный опыт libnovel.",
"subscribe_monthly_label": "Ежемесячно",
@@ -389,14 +362,12 @@
"subscribe_benefit_downloads": "Скачивайте главы для прослушивания офлайн",
"subscribe_login_prompt": "Войдите, чтобы оформить подписку",
"subscribe_login_cta": "Войти",
"user_currently_reading": "Сейчас читает",
"user_library_count": "Библиотека ({n})",
"user_joined": "Зарегистрирован {date}",
"user_followers_label": "подписчиков",
"user_following_label": "подписок",
"user_no_books": "Книг в библиотеке пока нет.",
"admin_pages_label": "Страницы",
"admin_tools_label": "Инструменты",
"admin_nav_scrape": "Скрейпинг",
@@ -412,7 +383,6 @@
"admin_nav_logs": "Логи",
"admin_nav_uptime": "Мониторинг",
"admin_nav_push": "Уведомления",
"admin_scrape_status_idle": "Ожидание",
"admin_scrape_full_catalogue": "Полный каталог",
"admin_scrape_single_book": "Одна книга",
@@ -423,25 +393,21 @@
"admin_scrape_start": "Начать парсинг",
"admin_scrape_queuing": "В очереди…",
"admin_scrape_running": "Выполняется…",
"admin_audio_filter_jobs": "Фильтр по slug, голосу или статусу…",
"admin_audio_filter_cache": "Фильтр по slug, главе или голосу…",
"admin_audio_no_matching_jobs": "Заданий не найдено.",
"admin_audio_no_jobs": "Аудиозаданий пока нет.",
"admin_audio_cache_empty": "Аудиокэш пуст.",
"admin_audio_no_cache_results": "Результатов нет.",
"admin_changelog_gitea": "Релизы Gitea",
"admin_changelog_no_releases": "Релизов не найдено.",
"admin_changelog_load_error": "Не удалось загрузить релизы: {error}",
"comments_top": "Лучшие",
"comments_new": "Новые",
"comments_posting": "Отправка…",
"comments_login_link": "Войдите",
"comments_login_suffix": "чтобы оставить комментарий.",
"comments_anonymous": "Аноним",
"reader_audio_narration": "Аудионарратив",
"reader_playing": "Воспроизводится — управление ниже",
"reader_paused": "Пауза — управление ниже",
@@ -454,7 +420,6 @@
"reader_voice_applies_next": "Новый голос применится при следующем нажатии «Воспроизвести».",
"reader_choose_voice": "Выбрать голос",
"reader_generating_narration": "Генерация озвучки…",
"profile_font_family": "Шрифт",
"profile_font_system": "Системный",
"profile_font_serif": "Serif",
@@ -464,7 +429,6 @@
"profile_text_size_md": "Нормальный",
"profile_text_size_lg": "Большой",
"profile_text_size_xl": "Очень большой",
"feed_page_title": "Лента — LibNovel",
"feed_heading": "Лента подписок",
"feed_subheading": "Книги, которые читают ваши подписки",
@@ -477,19 +441,17 @@
"feed_find_users_cta": "Найти читателей",
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana",
"admin_translation_page_title": "Translation \u2014 Admin",
"admin_translation_page_title": "Translation — Admin",
"admin_translation_heading": "Machine Translation",
"admin_translation_tab_enqueue": "Enqueue",
"admin_translation_tab_jobs": "Jobs",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status",
"admin_translation_no_matching": "No matching jobs.",
"admin_translation_no_jobs": "No translation jobs yet.",
"admin_ai_jobs_page_title": "AI Jobs \u2014 Admin",
"admin_ai_jobs_page_title": "AI Jobs — Admin",
"admin_ai_jobs_heading": "AI Jobs",
"admin_ai_jobs_subheading": "Background AI generation tasks",
"admin_text_gen_page_title": "Text Gen \u2014 Admin",
"admin_text_gen_heading": "Text Generation"
"admin_text_gen_page_title": "Text Gen — Admin",
"admin_text_gen_heading": "Text Generation",
"admin_nav_import": "Import"
}

View File

@@ -2,7 +2,7 @@
> Auto-generated i18n message functions. Import `messages.js` to use translated strings.
Compiled from: `/Users/kalekber/code/libnovel-v2/ui/project.inlang`
Compiled from: `/opt/libnovel-v3/ui/project.inlang`
## What is this folder?

View File

@@ -373,6 +373,7 @@ export * from './admin_tools_label.js'
export * from './admin_nav_scrape.js'
export * from './admin_nav_audio.js'
export * from './admin_nav_translation.js'
export * from './admin_nav_import.js'
export * from './admin_nav_changelog.js'
export * from './admin_nav_image_gen.js'
export * from './admin_nav_text_gen.js'
@@ -405,18 +406,6 @@ export * from './admin_audio_no_cache_results.js'
export * from './admin_changelog_gitea.js'
export * from './admin_changelog_no_releases.js'
export * from './admin_changelog_load_error.js'
export * from './admin_translation_page_title.js'
export * from './admin_translation_heading.js'
export * from './admin_translation_tab_enqueue.js'
export * from './admin_translation_tab_jobs.js'
export * from './admin_translation_filter_placeholder.js'
export * from './admin_translation_no_matching.js'
export * from './admin_translation_no_jobs.js'
export * from './admin_ai_jobs_page_title.js'
export * from './admin_ai_jobs_heading.js'
export * from './admin_ai_jobs_subheading.js'
export * from './admin_text_gen_page_title.js'
export * from './admin_text_gen_heading.js'
export * from './comments_top.js'
export * from './comments_new.js'
export * from './comments_posting.js'
@@ -453,4 +442,16 @@ export * from './feed_not_logged_in.js'
export * from './feed_reader_label.js'
export * from './feed_chapters_label.js'
export * from './feed_browse_cta.js'
export * from './feed_find_users_cta.js'
export * from './feed_find_users_cta.js'
export * from './admin_translation_page_title.js'
export * from './admin_translation_heading.js'
export * from './admin_translation_tab_enqueue.js'
export * from './admin_translation_tab_jobs.js'
export * from './admin_translation_filter_placeholder.js'
export * from './admin_translation_no_matching.js'
export * from './admin_translation_no_jobs.js'
export * from './admin_ai_jobs_page_title.js'
export * from './admin_ai_jobs_heading.js'
export * from './admin_ai_jobs_subheading.js'
export * from './admin_text_gen_page_title.js'
export * from './admin_text_gen_heading.js'

View File

@@ -26,6 +26,10 @@ const fr_admin_ai_jobs_heading = /** @type {(inputs: Admin_Ai_Jobs_HeadingInputs
};
/**
* | output |
* | --- |
* | "AI Jobs" |
*
* @param {Admin_Ai_Jobs_HeadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -37,4 +41,4 @@ export const admin_ai_jobs_heading = /** @type {((inputs?: Admin_Ai_Jobs_Heading
if (locale === "id") return id_admin_ai_jobs_heading(inputs)
if (locale === "pt") return pt_admin_ai_jobs_heading(inputs)
return fr_admin_ai_jobs_heading(inputs)
});
});

View File

@@ -26,6 +26,10 @@ const fr_admin_ai_jobs_page_title = /** @type {(inputs: Admin_Ai_Jobs_Page_Title
};
/**
* | output |
* | --- |
* | "AI Jobs — Admin" |
*
* @param {Admin_Ai_Jobs_Page_TitleInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -37,4 +41,4 @@ export const admin_ai_jobs_page_title = /** @type {((inputs?: Admin_Ai_Jobs_Page
if (locale === "id") return id_admin_ai_jobs_page_title(inputs)
if (locale === "pt") return pt_admin_ai_jobs_page_title(inputs)
return fr_admin_ai_jobs_page_title(inputs)
});
});

View File

@@ -26,6 +26,10 @@ const fr_admin_ai_jobs_subheading = /** @type {(inputs: Admin_Ai_Jobs_Subheading
};
/**
* | output |
* | --- |
* | "Background AI generation tasks" |
*
* @param {Admin_Ai_Jobs_SubheadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -37,4 +41,4 @@ export const admin_ai_jobs_subheading = /** @type {((inputs?: Admin_Ai_Jobs_Subh
if (locale === "id") return id_admin_ai_jobs_subheading(inputs)
if (locale === "pt") return pt_admin_ai_jobs_subheading(inputs)
return fr_admin_ai_jobs_subheading(inputs)
});
});

View File

@@ -26,6 +26,10 @@ const fr_admin_text_gen_heading = /** @type {(inputs: Admin_Text_Gen_HeadingInpu
};
/**
* | output |
* | --- |
* | "Text Generation" |
*
* @param {Admin_Text_Gen_HeadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -37,4 +41,4 @@ export const admin_text_gen_heading = /** @type {((inputs?: Admin_Text_Gen_Headi
if (locale === "id") return id_admin_text_gen_heading(inputs)
if (locale === "pt") return pt_admin_text_gen_heading(inputs)
return fr_admin_text_gen_heading(inputs)
});
});

View File

@@ -26,6 +26,10 @@ const fr_admin_text_gen_page_title = /** @type {(inputs: Admin_Text_Gen_Page_Tit
};
/**
* | output |
* | --- |
* | "Text Gen — Admin" |
*
* @param {Admin_Text_Gen_Page_TitleInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -37,4 +41,4 @@ export const admin_text_gen_page_title = /** @type {((inputs?: Admin_Text_Gen_Pa
if (locale === "id") return id_admin_text_gen_page_title(inputs)
if (locale === "pt") return pt_admin_text_gen_page_title(inputs)
return fr_admin_text_gen_page_title(inputs)
});
});

View File

@@ -26,6 +26,10 @@ const fr_admin_translation_filter_placeholder = /** @type {(inputs: Admin_Transl
};
/**
* | output |
* | --- |
* | "Filter by slug, lang, or status…" |
*
* @param {Admin_Translation_Filter_PlaceholderInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -37,4 +41,4 @@ export const admin_translation_filter_placeholder = /** @type {((inputs?: Admin_
if (locale === "id") return id_admin_translation_filter_placeholder(inputs)
if (locale === "pt") return pt_admin_translation_filter_placeholder(inputs)
return fr_admin_translation_filter_placeholder(inputs)
});
});

View File

@@ -26,6 +26,10 @@ const fr_admin_translation_heading = /** @type {(inputs: Admin_Translation_Headi
};
/**
* | output |
* | --- |
* | "Machine Translation" |
*
* @param {Admin_Translation_HeadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -37,4 +41,4 @@ export const admin_translation_heading = /** @type {((inputs?: Admin_Translation
if (locale === "id") return id_admin_translation_heading(inputs)
if (locale === "pt") return pt_admin_translation_heading(inputs)
return fr_admin_translation_heading(inputs)
});
});

View File

@@ -26,6 +26,10 @@ const fr_admin_translation_no_jobs = /** @type {(inputs: Admin_Translation_No_Jo
};
/**
* | output |
* | --- |
* | "No translation jobs yet." |
*
* @param {Admin_Translation_No_JobsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -37,4 +41,4 @@ export const admin_translation_no_jobs = /** @type {((inputs?: Admin_Translation
if (locale === "id") return id_admin_translation_no_jobs(inputs)
if (locale === "pt") return pt_admin_translation_no_jobs(inputs)
return fr_admin_translation_no_jobs(inputs)
});
});

View File

@@ -26,6 +26,10 @@ const fr_admin_translation_no_matching = /** @type {(inputs: Admin_Translation_N
};
/**
* | output |
* | --- |
* | "No matching jobs." |
*
* @param {Admin_Translation_No_MatchingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -37,4 +41,4 @@ export const admin_translation_no_matching = /** @type {((inputs?: Admin_Transla
if (locale === "id") return id_admin_translation_no_matching(inputs)
if (locale === "pt") return pt_admin_translation_no_matching(inputs)
return fr_admin_translation_no_matching(inputs)
});
});

View File

@@ -26,6 +26,10 @@ const fr_admin_translation_page_title = /** @type {(inputs: Admin_Translation_Pa
};
/**
* | output |
* | --- |
* | "Translation — Admin" |
*
* @param {Admin_Translation_Page_TitleInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -37,4 +41,4 @@ export const admin_translation_page_title = /** @type {((inputs?: Admin_Translat
if (locale === "id") return id_admin_translation_page_title(inputs)
if (locale === "pt") return pt_admin_translation_page_title(inputs)
return fr_admin_translation_page_title(inputs)
});
});

View File

@@ -26,6 +26,10 @@ const fr_admin_translation_tab_enqueue = /** @type {(inputs: Admin_Translation_T
};
/**
* | output |
* | --- |
* | "Enqueue" |
*
* @param {Admin_Translation_Tab_EnqueueInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -37,4 +41,4 @@ export const admin_translation_tab_enqueue = /** @type {((inputs?: Admin_Transla
if (locale === "id") return id_admin_translation_tab_enqueue(inputs)
if (locale === "pt") return pt_admin_translation_tab_enqueue(inputs)
return fr_admin_translation_tab_enqueue(inputs)
});
});

View File

@@ -26,6 +26,10 @@ const fr_admin_translation_tab_jobs = /** @type {(inputs: Admin_Translation_Tab_
};
/**
* | output |
* | --- |
* | "Jobs" |
*
* @param {Admin_Translation_Tab_JobsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -37,4 +41,4 @@ export const admin_translation_tab_jobs = /** @type {((inputs?: Admin_Translatio
if (locale === "id") return id_admin_translation_tab_jobs(inputs)
if (locale === "pt") return pt_admin_translation_tab_jobs(inputs)
return fr_admin_translation_tab_jobs(inputs)
});
});

View File

@@ -41,4 +41,4 @@ export const profile_theme_cyber = /** @type {((inputs?: Profile_Theme_CyberInpu
if (locale === "id") return id_profile_theme_cyber(inputs)
if (locale === "pt") return pt_profile_theme_cyber(inputs)
return fr_profile_theme_cyber(inputs)
});
});

View File

@@ -41,4 +41,4 @@ export const profile_theme_forest = /** @type {((inputs?: Profile_Theme_ForestIn
if (locale === "id") return id_profile_theme_forest(inputs)
if (locale === "pt") return pt_profile_theme_forest(inputs)
return fr_profile_theme_forest(inputs)
});
});

View File

@@ -41,4 +41,4 @@ export const profile_theme_mono = /** @type {((inputs?: Profile_Theme_MonoInputs
if (locale === "id") return id_profile_theme_mono(inputs)
if (locale === "pt") return pt_profile_theme_mono(inputs)
return fr_profile_theme_mono(inputs)
});
});

View File

@@ -23,6 +23,28 @@
// Universal search
let searchOpen = $state(false);
// Notifications
let notificationsOpen = $state(false);
let notifications = $state<{id: string; title: string; message: string; link: string; read: boolean}[]>([]);
async function loadNotifications() {
if (!data.user) return;
try {
const res = await fetch('/api/notifications?user_id=' + data.user.id);
if (res.ok) {
const d = await res.json();
notifications = d.notifications || [];
}
} catch (e) { console.error('load notifications:', e); }
}
async function markRead(id: string) {
try {
await fetch('/api/notifications/' + id, { method: 'PATCH' });
notifications = notifications.map(n => n.id === id ? {...n, read: true} : n);
} catch (e) { console.error('mark read:', e); }
}
$effect(() => { if (data.user) loadNotifications(); });
const unreadCount = $derived(notifications.filter(n => !n.read).length);
// Close search on navigation
$effect(() => {
void page.url.pathname;
@@ -529,7 +551,7 @@
{#if !/\/books\/[^/]+\/chapters\//.test(page.url.pathname)}
<button
type="button"
onclick={() => { searchOpen = true; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = false; }}
onclick={() => { searchOpen = true; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = false; notificationsOpen = false; }}
title="Search (/ or ⌘K)"
aria-label="Search books"
class="flex items-center justify-center w-8 h-8 rounded transition-colors text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)"
@@ -539,6 +561,43 @@
</svg>
</button>
{/if}
<!-- Notifications bell -->
{#if data.user?.role === 'admin'}
<div class="relative">
<button
type="button"
onclick={() => { notificationsOpen = !notificationsOpen; searchOpen = false; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; }}
title="Notifications"
class="flex items-center justify-center w-8 h-8 rounded transition-colors {notificationsOpen ? 'bg-(--color-surface-2)' : 'hover:bg-(--color-surface-2)'} relative"
>
<svg class="w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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"/>
</svg>
{#if unreadCount > 0}
<span class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
{/if}
</button>
{#if notificationsOpen}
<div class="absolute right-0 top-full mt-1 w-80 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl z-50 max-h-96 overflow-y-auto">
{#if notifications.length === 0}
<div class="p-4 text-center text-(--color-muted) text-sm">No notifications</div>
{:else}
{#each notifications as n}
<a
href={n.link || '/admin/import'}
onclick={() => { markRead(n.id); notificationsOpen = false; }}
class="block p-3 border-b border-(--color-border)/50 hover:bg-(--color-surface-3) {n.read ? 'opacity-60' : ''}"
>
<div class="text-sm font-medium">{n.title}</div>
<div class="text-xs text-(--color-muted) mt-0.5">{n.message}</div>
</a>
{/each}
{/if}
</div>
{/if}
</div>
{/if}
<!-- Theme dropdown (desktop) -->
<div class="hidden sm:block relative">
<button

View File

@@ -18,6 +18,26 @@
label: () => m.admin_nav_translation(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />`
},
{
href: '/admin/import',
label: () => m.admin_nav_import(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />`
},
{
href: '/admin/image-gen',
label: () => m.admin_nav_image_gen(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />`
},
{
href: '/admin/audio',
label: () => m.admin_nav_audio(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />`
},
{
href: '/admin/translation',
label: () => m.admin_nav_translation(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />`
},
{
href: '/admin/image-gen',
label: () => m.admin_nav_image_gen(),

View File

@@ -0,0 +1,262 @@
<script lang="ts">
import { onMount } from 'svelte';
interface ImportTask {
id: string;
slug: string;
title: string;
file_name: string;
file_type: string;
status: string;
chapters_done: number;
chapters_total: number;
error_message: string;
started: string;
finished: string;
}
interface PendingImport {
file: File;
title: 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);
async function loadTasks() {
loading = true;
try {
const res = await fetch('/api/admin/import');
if (res.ok) {
const data = await res.json();
tasks = data.tasks || [];
}
} catch (e) {
console.error('Failed to load tasks:', e);
} finally {
loading = false;
}
}
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (!input.files?.length) return;
const file = input.files[0];
const ext = file.name.split('.').pop()?.toLowerCase() || '';
if (ext !== 'pdf' && ext !== 'epub') {
error = 'Please select a PDF or EPUB file';
return;
}
selectedFile = file;
title = file.name.replace(/\.(pdf|epub)$/i, '').replace(/[-_]/g, ' ');
}
async function analyzeFile() {
if (!selectedFile || !title.trim()) return;
analyzing = true;
error = '';
try {
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('title', title);
formData.append('analyze', 'true');
const res = await fetch('/api/admin/import', {
method: 'POST',
body: formData
});
if (res.ok) {
const data = await res.json();
pendingImport = {
file: selectedFile,
title: title,
preview: data.preview || { chapters: 0, firstLines: [] }
};
} else {
const d = await res.json().catch(() => ({}));
error = d.error || 'Failed to analyze file';
}
} catch (e) {
error = 'Failed to analyze file';
} finally {
analyzing = false;
}
}
async function startImport() {
if (!pendingImport) return;
uploading = true;
error = '';
try {
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
});
if (res.ok) {
pendingImport = null;
selectedFile = null;
title = '';
await loadTasks();
} else {
const d = await res.json().catch(() => ({}));
error = d.error || 'Import failed';
}
} catch (e) {
error = 'Import failed';
} finally {
uploading = false;
}
}
function cancelReview() {
pendingImport = null;
}
function formatDate(dateStr: string) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString();
}
function getStatusColor(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';
}
}
onMount(() => {
loadTasks();
});
</script>
<div class="max-w-4xl">
<h1 class="text-2xl font-bold mb-6">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>
</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>
</div>
{/if}
</div>
<div class="flex gap-3">
<button
onclick={startImport}
disabled={uploading}
class="px-4 py-2 bg-green-600 text-white rounded font-medium disabled:opacity-50"
>
{uploading ? 'Starting...' : 'Start Import'}
</button>
<button
onclick={cancelReview}
class="px-4 py-2 border border-(--color-border) rounded font-medium"
>
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 class="block text-sm font-medium mb-2">Select File (PDF or EPUB)</label>
<input
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)"
/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2">Book Title</label>
<input
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)"
/>
</div>
{#if error}
<p class="mb-4 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"
>
{analyzing ? 'Analyzing...' : 'Review & Import'}
</button>
<p class="mt-2 text-xs text-(--color-muted)">
Select a file to preview chapter count before importing.
</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>
</div>
{/if}
</div>

View File

@@ -0,0 +1,38 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { backendFetch } from '$lib/server/scraper';
/**
* GET /api/admin/import
* List all import tasks.
*/
export const GET: RequestHandler = async ({ locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const res = await backendFetch('/api/admin/import', { method: 'GET' });
const data = await res.json().catch(() => ({ tasks: [] }));
return json(data);
};
/**
* POST /api/admin/import
* Create a new import task.
*/
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const body = await request.json();
const res = await backendFetch('/api/admin/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Failed to create import task' }));
throw error(res.status, err.error || 'Failed to create import task');
}
const data = await res.json();
return json(data);
};