Compare commits

..

1 Commits

Author SHA1 Message Date
root
6617828487 feat: add PDF/EPUB import functionality
Some checks failed
Release / Test backend (push) Failing after 25s
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
- Add import task handling to runner (polling + Asynq handler)
- Add BookImporter interface to bookstore for PDF/EPUB parsing
- Add backend API endpoints: POST/GET /api/admin/import
- Add SvelteKit UI at /admin/import with task list
- Add nav link in admin layout

Note: PDF/EPUB parsing is a placeholder - needs external library integration.
2026-04-09 09:46:51 +05:00
53 changed files with 590 additions and 799 deletions

View File

@@ -0,0 +1,165 @@
package main
import (
"fmt"
"log"
"os"
"regexp"
"strings"
"github.com/ledongthuc/pdf"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: pdf-to-chapters <input.pdf>")
os.Exit(1)
}
inputPath := os.Args[1]
if err := processPDF(inputPath); err != nil {
log.Fatal(err)
}
}
func processPDF(inputPath string) error {
pdf.DebugOn = false
f, r, err := pdf.Open(inputPath)
if err != nil {
return fmt.Errorf("failed to open PDF: %w", err)
}
defer f.Close()
totalPages := r.NumPage()
fmt.Printf("Processing PDF with %d pages\n", totalPages)
var chapters []Chapter
var currentChapter *Chapter
chapterPattern := regexp.MustCompile(`The Eminence in Shadow\s+(\d+)\s*-\s*(\d+)`)
for i := 1; i <= totalPages; i++ {
page := r.Page(i)
if err := page.IsValid(); err != nil {
log.Printf("Warning: page %d not valid: %v", i, err)
continue
}
text, err := page.GetPlainText(nil)
if err != nil {
log.Printf("Warning: failed to extract text from page %d: %v", i, err)
continue
}
// Check for chapter header on this page
matches := chapterPattern.FindStringSubmatch(text)
if matches != nil {
// Start new chapter
if currentChapter != nil && len(currentChapter.Content) > 0 {
chapters = append(chapters, *currentChapter)
}
chapterNum := matches[1]
currentChapter = &Chapter{
Number: chapterNum,
StartPage: i,
Content: text,
}
continue
}
// Append to current chapter
if currentChapter != nil {
currentChapter.Content += "\n" + text
}
}
// Don't forget last chapter
if currentChapter != nil && len(currentChapter.Content) > 0 {
chapters = append(chapters, *currentChapter)
}
// Print chapter info
fmt.Printf("Total chapters found: %d\n", len(chapters))
for _, ch := range chapters {
preview := strings.TrimSpace(ch.Content)
if len(preview) > 200 {
preview = preview[:200] + "..."
}
fmt.Printf("Chapter %s (page %d): %s\n", ch.Number, ch.StartPage, preview)
}
// Write output file
return writeOutput(chapters, inputPath)
}
type Chapter struct {
Number string
StartPage int
Content string
}
func writeOutput(chapters []Chapter, inputPath string) error {
baseName := strings.TrimSuffix(inputPath, ".pdf")
outPath := baseName + "_chapters.txt"
f, err := os.Create(outPath)
if err != nil {
return fmt.Errorf("failed to create output: %w", err)
}
defer f.Close()
for i, ch := range chapters {
if i > 0 {
fmt.Fprintln(f)
}
fmt.Fprintf(f, "## Chapter %s\n\n", ch.Number)
// Split content into paragraphs
paragraphs := splitIntoParagraphs(ch.Content)
for _, para := range paragraphs {
trimmed := strings.TrimSpace(para)
if len(trimmed) > 0 {
fmt.Fprintln(f, trimmed)
fmt.Fprintln(f)
}
}
}
fmt.Printf("\nOutput written to: %s\n", outPath)
return nil
}
func splitIntoParagraphs(text string) []string {
lines := strings.Split(text, "\n")
var paragraphs []string
var currentPara strings.Builder
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// Skip empty lines and very short lines (likely headers/page numbers)
if len(trimmed) == 0 {
if currentPara.Len() > 0 {
paragraphs = append(paragraphs, currentPara.String())
currentPara.Reset()
}
continue
}
if len(trimmed) < 3 {
continue
}
if currentPara.Len() > 0 {
currentPara.WriteString(" ")
}
currentPara.WriteString(trimmed)
}
if currentPara.Len() > 0 {
paragraphs = append(paragraphs, currentPara.String())
}
return paragraphs
}

View File

@@ -197,15 +197,12 @@ func run() error {
AudioStore: store,
CoverStore: store,
TranslationStore: store,
BookImport: storage.NewBookImporter(store),
ChapterIngester: store,
SearchIndex: searchIndex,
Novel: novel,
Kokoro: kokoroClient,
PocketTTS: pocketTTSClient,
CFAI: cfaiClient,
LibreTranslate: ltClient,
Notifier: store,
Log: log,
}
r := runner.New(rCfg, deps)

View File

@@ -3,6 +3,7 @@ module github.com/libnovel/backend
go 1.26.1
require (
github.com/ledongthuc/pdf v0.0.0-20241014091450-14fc3c58b12d
github.com/minio/minio-go/v7 v7.0.98
golang.org/x/net v0.51.0
)

View File

@@ -40,10 +40,6 @@ 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)
}
@@ -64,12 +60,6 @@ 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

@@ -88,8 +88,8 @@ func (p *Producer) CreateTranslationTask(ctx context.Context, slug string, chapt
}
// CreateImportTask creates a PocketBase record then enqueues an Asynq job for PDF/EPUB import.
func (p *Producer) CreateImportTask(ctx context.Context, slug, title, fileType, objectKey, initiatorUserID string) (string, error) {
id, err := p.pb.CreateImportTask(ctx, slug, title, fileType, objectKey, initiatorUserID)
func (p *Producer) CreateImportTask(ctx context.Context, slug, title, fileType, objectKey string) (string, error) {
id, err := p.pb.CreateImportTask(ctx, slug, title, fileType, objectKey)
if err != nil {
return "", err
}

View File

@@ -1,6 +1,7 @@
package backend
import (
"context"
"encoding/json"
"fmt"
"io"
@@ -9,29 +10,23 @@ import (
"strings"
"time"
"github.com/libnovel/backend/internal/storage"
"github.com/libnovel/backend/internal/asynqqueue"
)
type importRequest struct {
Title string `json:"title"`
FileName string `json:"file_name"`
FileType string `json:"file_type"` // "pdf" or "epub"
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"`
TaskID string `json:"task_id"`
Slug string `json:"slug"`
}
func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
if s.deps.Producer == nil {
if s.deps.TaskProducer == nil {
jsonError(w, http.StatusServiceUnavailable, "task queue not configured")
return
}
@@ -48,7 +43,6 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
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 {
@@ -70,23 +64,8 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
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 {
if err := s.deps.PresignStore.PutObject(r.Context(), "imports", objectKey, data); err != nil {
jsonError(w, http.StatusInternalServerError, "upload file: "+err.Error())
return
}
@@ -115,7 +94,7 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
return -1
}, slug)
taskID, err := s.deps.Producer.CreateImportTask(r.Context(), slug, req.Title, req.FileType, objectKey, "")
taskID, err := s.deps.TaskProducer.CreateImportTask(r.Context(), slug, req.Title, req.FileType, objectKey)
if err != nil {
jsonError(w, http.StatusInternalServerError, "create import task: "+err.Error())
return
@@ -127,27 +106,6 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
})
}
// 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 == "" {
@@ -155,12 +113,8 @@ func (s *Server) handleAdminImportStatus(w http.ResponseWriter, r *http.Request)
return
}
task, ok, err := s.deps.TaskReader.GetImportTask(r.Context(), taskID)
task, 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
}

View File

@@ -1,69 +0,0 @@
package backend
import (
"encoding/json"
"net/http"
"github.com/libnovel/backend/internal/storage"
)
type notification struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Title string `json:"title"`
Message string `json:"message"`
Link string `json:"link"`
Read bool `json:"read"`
}
func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("user_id")
if userID == "" {
jsonError(w, http.StatusBadRequest, "user_id required")
return
}
store, ok := s.deps.Producer.(*storage.Store)
if !ok {
jsonError(w, http.StatusInternalServerError, "storage not available")
return
}
items, err := store.ListNotifications(r.Context(), userID, 50)
if err != nil {
jsonError(w, http.StatusInternalServerError, "list notifications: "+err.Error())
return
}
// Parse each item as notification
notifications := make([]notification, 0, len(items))
for _, item := range items {
b, _ := json.Marshal(item)
var n notification
json.Unmarshal(b, &n)
notifications = append(notifications, n)
}
writeJSON(w, 0, map[string]any{"notifications": notifications})
}
func (s *Server) handleMarkNotificationRead(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
jsonError(w, http.StatusBadRequest, "notification id required")
return
}
store, ok := s.deps.Producer.(*storage.Store)
if !ok {
jsonError(w, http.StatusInternalServerError, "storage not available")
return
}
if err := store.MarkNotificationRead(r.Context(), id); err != nil {
jsonError(w, http.StatusInternalServerError, "mark read: "+err.Error())
return
}
writeJSON(w, 0, map[string]any{"success": true})
}

View File

@@ -249,10 +249,6 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
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

@@ -172,19 +172,18 @@ type TranslationResult struct {
// 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"`
InitiatorUserID string `json:"initiator_user_id,omitempty"` // PocketBase user ID who submitted the import
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"`
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.

View File

@@ -39,16 +39,6 @@ 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
}
// ChapterIngester persists imported chapters for a book.
type ChapterIngester interface {
IngestChapters(ctx context.Context, slug string, chapters []bookstore.Chapter) error
}
// Config tunes the runner behaviour.
type Config struct {
// WorkerID uniquely identifies this runner instance in PocketBase records.
@@ -115,10 +105,6 @@ type Dependencies struct {
CoverStore bookstore.CoverStore
// BookImport handles PDF/EPUB file parsing and chapter extraction.
BookImport bookstore.BookImporter
// ChapterIngester persists extracted chapters into MinIO/PocketBase.
ChapterIngester ChapterIngester
// Notifier creates notifications for users.
Notifier Notifier
// SearchIndex indexes books in Meilisearch after scraping.
// If nil a no-op is used.
SearchIndex meili.Client
@@ -719,12 +705,9 @@ func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, obje
})
}
// Store chapters via ChapterIngester
if r.deps.ChapterIngester == nil {
fail("chapter ingester not configured")
return
}
if err := r.deps.ChapterIngester.IngestChapters(ctx, task.Slug, domainChapters); err != nil {
// 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
}
@@ -738,16 +721,21 @@ func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, obje
if err := r.deps.Consumer.FinishImportTask(ctx, task.ID, result); err != nil {
log.Error("runner: FinishImportTask failed", "err", err)
}
// Create notification for the user who initiated the import
if r.deps.Notifier != nil {
msg := fmt.Sprintf("Import completed: %d chapters from %s", len(chapters), task.Title)
targetUser := task.InitiatorUserID
if targetUser == "" {
targetUser = "admin"
}
_ = r.deps.Notifier.CreateNotification(ctx, targetUser, "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 {
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)
// 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,10 +54,6 @@ 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
@@ -73,11 +69,6 @@ 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

@@ -1,6 +1,7 @@
package storage
import (
"bytes"
"context"
"errors"
"fmt"
@@ -9,7 +10,7 @@ import (
"strings"
"github.com/libnovel/backend/internal/bookstore"
minio "github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7"
)
var (
@@ -17,12 +18,12 @@ var (
)
type importer struct {
mc *minioClient
mc *minio.Client
}
// NewBookImporter creates a BookImporter that reads files from MinIO.
func NewBookImporter(s *Store) bookstore.BookImporter {
return &importer{mc: s.mc}
func NewBookImporter(mc *minio.Client) bookstore.BookImporter {
return &importer{mc: mc}
}
func (i *importer) Import(ctx context.Context, objectKey, fileType string) ([]bookstore.Chapter, error) {
@@ -30,7 +31,7 @@ func (i *importer) Import(ctx context.Context, objectKey, fileType string) ([]bo
return nil, fmt.Errorf("unsupported file type: %s", fileType)
}
obj, err := i.mc.client.GetObject(ctx, "imports", objectKey, minio.GetObjectOptions{})
obj, err := i.mc.GetObject(ctx, "imports", objectKey, minio.GetObjectOptions{})
if err != nil {
return nil, fmt.Errorf("get object from minio: %w", err)
}
@@ -128,7 +129,9 @@ func (s *Store) IngestChapters(ctx context.Context, slug string, chapters []book
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 {
if err := s.mc.PutObject(ctx, "books", key, strings.NewReader(content), int64(len(content)), minio.PutObjectOptions{
ContentType: "text/markdown",
}); err != nil {
return fmt.Errorf("put chapter %d: %w", ch.Number, err)
}
}
@@ -141,4 +144,23 @@ func (s *Store) IngestChapters(ctx context.Context, slug string, chapters []book
// 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,17 +647,16 @@ func (s *Store) CreateTranslationTask(ctx context.Context, slug string, chapter
return rec.ID, nil
}
func (s *Store) CreateImportTask(ctx context.Context, slug, title, fileType, objectKey, initiatorUserID string) (string, error) {
func (s *Store) CreateImportTask(ctx context.Context, 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),
"initiator_user_id": initiatorUserID,
"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"`
@@ -668,46 +667,6 @@ func (s *Store) CreateImportTask(ctx context.Context, slug, title, fileType, obj
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),
@@ -1020,6 +979,9 @@ func (s *Store) GetImportTask(ctx context.Context, id string) (domain.ImportTask
t, err := parseImportTask(raw)
return t, err == nil, err
}
t, err := parseTranslationTask(items[0])
return t, err == nil, err
}
// ── Parsers ───────────────────────────────────────────────────────────────────
@@ -1135,7 +1097,6 @@ func parseImportTask(raw json.RawMessage) (domain.ImportTask, error) {
FileName string `json:"file_name"`
FileType string `json:"file_type"`
WorkerID string `json:"worker_id"`
InitiatorUserID string `json:"initiator_user_id"`
Status string `json:"status"`
ChaptersDone int `json:"chapters_done"`
ChaptersTotal int `json:"chapters_total"`
@@ -1155,7 +1116,6 @@ func parseImportTask(raw json.RawMessage) (domain.ImportTask, error) {
FileName: rec.FileName,
FileType: rec.FileType,
WorkerID: rec.WorkerID,
InitiatorUserID: rec.InitiatorUserID,
Status: domain.TaskStatus(rec.Status),
ChaptersDone: rec.ChaptersDone,
ChaptersTotal: rec.ChaptersTotal,
@@ -1191,11 +1151,6 @@ 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

@@ -35,8 +35,7 @@ type Producer interface {
// CreateImportTask inserts a new import task with status=pending and
// returns the assigned PocketBase record ID.
// initiatorUserID is the PocketBase user ID who submitted the import (may be empty).
CreateImportTask(ctx context.Context, slug, title, fileType, objectKey, initiatorUserID string) (string, error)
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.

View File

@@ -26,9 +26,6 @@ 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 }
@@ -41,9 +38,6 @@ 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
}
@@ -53,9 +47,6 @@ 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 }
@@ -78,10 +69,6 @@ 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,7 +402,6 @@
"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,5 +1,6 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_library": "Bibliothèque",
"nav_catalogue": "Catalogue",
"nav_feed": "Fil",
@@ -10,6 +11,7 @@
"nav_sign_out": "Déconnexion",
"nav_toggle_menu": "Menu",
"nav_admin_panel": "Panneau admin",
"footer_library": "Bibliothèque",
"footer_catalogue": "Catalogue",
"footer_feedback": "Retour",
@@ -18,6 +20,7 @@
"footer_dmca": "DMCA",
"footer_copyright": "© {year} libnovel",
"footer_dev": "dev",
"home_title": "libnovel",
"home_stat_books": "Livres",
"home_stat_chapters": "Chapitres",
@@ -31,6 +34,7 @@
"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",
@@ -54,6 +58,7 @@
"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",
@@ -63,6 +68,7 @@
"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",
@@ -72,6 +78,7 @@
"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…",
@@ -92,6 +99,7 @@
"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",
@@ -108,11 +116,13 @@
"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…",
@@ -134,6 +144,7 @@
"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",
@@ -168,6 +179,7 @@
"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",
@@ -175,11 +187,13 @@
"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",
@@ -197,11 +211,14 @@
"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…",
@@ -215,10 +232,12 @@
"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",
@@ -232,12 +251,15 @@
"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",
@@ -270,6 +292,7 @@
"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",
@@ -305,15 +328,18 @@
"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",
@@ -331,6 +357,7 @@
"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",
@@ -342,7 +369,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 libnovel",
"subscribe_page_title": "Passer Pro \u2014 libnovel",
"subscribe_heading": "Lisez plus. Écoutez plus.",
"subscribe_subheading": "Passez Pro et débloquez l'expérience libnovel complète.",
"subscribe_monthly_label": "Mensuel",
@@ -362,12 +389,14 @@
"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",
@@ -383,6 +412,7 @@
"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",
@@ -393,21 +423,25 @@
"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",
@@ -420,6 +454,7 @@
"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",
@@ -429,6 +464,7 @@
"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",
@@ -441,17 +477,19 @@
"feed_find_users_cta": "Trouver des lecteurs",
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana",
"admin_translation_page_title": "Translation — Admin",
"admin_translation_page_title": "Translation \u2014 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",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026",
"admin_translation_no_matching": "No matching jobs.",
"admin_translation_no_jobs": "No translation jobs yet.",
"admin_ai_jobs_page_title": "AI Jobs — Admin",
"admin_ai_jobs_page_title": "AI Jobs \u2014 Admin",
"admin_ai_jobs_heading": "AI Jobs",
"admin_ai_jobs_subheading": "Background AI generation tasks",
"admin_text_gen_page_title": "Text Gen — Admin",
"admin_text_gen_heading": "Text Generation",
"admin_nav_import": "Import"
"admin_text_gen_page_title": "Text Gen \u2014 Admin",
"admin_text_gen_heading": "Text Generation"
}

View File

@@ -1,5 +1,6 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_library": "Perpustakaan",
"nav_catalogue": "Katalog",
"nav_feed": "Umpan",
@@ -10,6 +11,7 @@
"nav_sign_out": "Keluar",
"nav_toggle_menu": "Menu",
"nav_admin_panel": "Panel admin",
"footer_library": "Perpustakaan",
"footer_catalogue": "Katalog",
"footer_feedback": "Masukan",
@@ -18,6 +20,7 @@
"footer_dmca": "DMCA",
"footer_copyright": "© {year} libnovel",
"footer_dev": "dev",
"home_title": "libnovel",
"home_stat_books": "Buku",
"home_stat_chapters": "Bab",
@@ -31,6 +34,7 @@
"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",
@@ -54,6 +58,7 @@
"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",
@@ -63,6 +68,7 @@
"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",
@@ -72,6 +78,7 @@
"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…",
@@ -92,6 +99,7 @@
"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",
@@ -108,11 +116,13 @@
"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…",
@@ -134,6 +144,7 @@
"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",
@@ -168,6 +179,7 @@
"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",
@@ -175,11 +187,13 @@
"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",
@@ -197,11 +211,14 @@
"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…",
@@ -215,10 +232,12 @@
"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",
@@ -232,12 +251,15 @@
"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",
@@ -270,6 +292,7 @@
"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",
@@ -305,15 +328,18 @@
"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",
@@ -331,6 +357,7 @@
"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",
@@ -342,7 +369,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 libnovel",
"subscribe_page_title": "Jadi Pro \u2014 libnovel",
"subscribe_heading": "Baca lebih. Dengarkan lebih.",
"subscribe_subheading": "Tingkatkan ke Pro dan buka pengalaman libnovel sepenuhnya.",
"subscribe_monthly_label": "Bulanan",
@@ -362,12 +389,14 @@
"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",
@@ -383,6 +412,7 @@
"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",
@@ -393,21 +423,25 @@
"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",
@@ -420,6 +454,7 @@
"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",
@@ -429,6 +464,7 @@
"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",
@@ -441,17 +477,19 @@
"feed_find_users_cta": "Temukan pembaca",
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana",
"admin_translation_page_title": "Translation — Admin",
"admin_translation_page_title": "Translation \u2014 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",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026",
"admin_translation_no_matching": "No matching jobs.",
"admin_translation_no_jobs": "No translation jobs yet.",
"admin_ai_jobs_page_title": "AI Jobs — Admin",
"admin_ai_jobs_page_title": "AI Jobs \u2014 Admin",
"admin_ai_jobs_heading": "AI Jobs",
"admin_ai_jobs_subheading": "Background AI generation tasks",
"admin_text_gen_page_title": "Text Gen — Admin",
"admin_text_gen_heading": "Text Generation",
"admin_nav_import": "Import"
"admin_text_gen_page_title": "Text Gen \u2014 Admin",
"admin_text_gen_heading": "Text Generation"
}

View File

@@ -1,5 +1,6 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_library": "Biblioteca",
"nav_catalogue": "Catálogo",
"nav_feed": "Feed",
@@ -10,6 +11,7 @@
"nav_sign_out": "Sair",
"nav_toggle_menu": "Menu",
"nav_admin_panel": "Painel admin",
"footer_library": "Biblioteca",
"footer_catalogue": "Catálogo",
"footer_feedback": "Feedback",
@@ -18,6 +20,7 @@
"footer_dmca": "DMCA",
"footer_copyright": "© {year} libnovel",
"footer_dev": "dev",
"home_title": "libnovel",
"home_stat_books": "Livros",
"home_stat_chapters": "Capítulos",
@@ -31,6 +34,7 @@
"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",
@@ -54,6 +58,7 @@
"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",
@@ -63,6 +68,7 @@
"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",
@@ -72,6 +78,7 @@
"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…",
@@ -92,6 +99,7 @@
"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",
@@ -108,11 +116,13 @@
"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…",
@@ -134,6 +144,7 @@
"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",
@@ -168,6 +179,7 @@
"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",
@@ -175,11 +187,13 @@
"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",
@@ -197,11 +211,14 @@
"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…",
@@ -215,10 +232,12 @@
"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",
@@ -232,12 +251,15 @@
"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",
@@ -270,6 +292,7 @@
"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",
@@ -305,15 +328,18 @@
"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",
@@ -331,6 +357,7 @@
"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",
@@ -342,7 +369,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 libnovel",
"subscribe_page_title": "Seja Pro \u2014 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",
@@ -362,12 +389,14 @@
"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",
@@ -383,6 +412,7 @@
"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",
@@ -393,21 +423,25 @@
"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",
@@ -420,6 +454,7 @@
"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",
@@ -429,6 +464,7 @@
"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",
@@ -441,17 +477,19 @@
"feed_find_users_cta": "Encontrar leitores",
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana",
"admin_translation_page_title": "Translation — Admin",
"admin_translation_page_title": "Translation \u2014 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",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026",
"admin_translation_no_matching": "No matching jobs.",
"admin_translation_no_jobs": "No translation jobs yet.",
"admin_ai_jobs_page_title": "AI Jobs — Admin",
"admin_ai_jobs_page_title": "AI Jobs \u2014 Admin",
"admin_ai_jobs_heading": "AI Jobs",
"admin_ai_jobs_subheading": "Background AI generation tasks",
"admin_text_gen_page_title": "Text Gen — Admin",
"admin_text_gen_heading": "Text Generation",
"admin_nav_import": "Import"
"admin_text_gen_page_title": "Text Gen \u2014 Admin",
"admin_text_gen_heading": "Text Generation"
}

View File

@@ -1,5 +1,6 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_library": "Библиотека",
"nav_catalogue": "Каталог",
"nav_feed": "Лента",
@@ -10,6 +11,7 @@
"nav_sign_out": "Выйти",
"nav_toggle_menu": "Меню",
"nav_admin_panel": "Панель администратора",
"footer_library": "Библиотека",
"footer_catalogue": "Каталог",
"footer_feedback": "Обратная связь",
@@ -18,6 +20,7 @@
"footer_dmca": "DMCA",
"footer_copyright": "© {year} libnovel",
"footer_dev": "dev",
"home_title": "libnovel",
"home_stat_books": "Книги",
"home_stat_chapters": "Главы",
@@ -31,6 +34,7 @@
"home_discover_novels": "Открыть новеллы",
"home_via_reader": "от {username}",
"home_chapter_badge": "гл.{n}",
"player_generating": "Генерация… {percent}%",
"player_loading": "Загрузка…",
"player_chapters": "Главы",
@@ -54,6 +58,7 @@
"player_auto_next_aria": "Автопереход {state}",
"player_go_to_chapter": "Перейти к главе",
"player_close": "Закрыть плеер",
"login_page_title": "Вход — libnovel",
"login_heading": "Войти в libnovel",
"login_subheading": "Выберите провайдера для входа",
@@ -63,6 +68,7 @@
"login_error_oauth_state": "Вход отменён или истёк срок действия. Попробуйте снова.",
"login_error_oauth_failed": "Не удалось подключиться к провайдеру. Попробуйте снова.",
"login_error_oauth_no_email": "У вашего аккаунта нет подтверждённого email. Добавьте его и повторите попытку.",
"books_page_title": "Библиотека — libnovel",
"books_heading": "Ваша библиотека",
"books_empty_title": "Книг пока нет",
@@ -72,6 +78,7 @@
"books_last_read": "Последнее: гл.{n}",
"books_reading_progress": "Гл.{current} / {total}",
"books_remove": "Удалить",
"catalogue_page_title": "Каталог — libnovel",
"catalogue_heading": "Каталог",
"catalogue_search_placeholder": "Поиск новелл…",
@@ -92,6 +99,7 @@
"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": "В библиотеку",
@@ -108,11 +116,13 @@
"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": "Генерация аудио…",
@@ -134,6 +144,7 @@
"reader_auto_next": "Автопереход",
"reader_speed": "Скорость",
"reader_preview_notice": "Предпросмотр — эта глава не полностью загружена.",
"profile_page_title": "Профиль — libnovel",
"profile_heading": "Профиль",
"profile_avatar_label": "Аватар",
@@ -168,6 +179,7 @@
"profile_sessions_heading": "Активные сессии",
"profile_sign_out_all": "Выйти на всех других устройствах",
"profile_joined": "Зарегистрирован {date}",
"user_page_title": "{username} — libnovel",
"user_library_heading": "Библиотека {username}",
"user_follow": "Подписаться",
@@ -175,11 +187,13 @@
"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": "Парсинг каталога",
@@ -197,11 +211,14 @@
"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": "Написать комментарий…",
@@ -215,10 +232,12 @@
"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": "Сохранить",
@@ -232,12 +251,15 @@
"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": "Обновить",
@@ -270,6 +292,7 @@
"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",
@@ -305,15 +328,18 @@
"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": "Автопереход к следующей главе",
@@ -331,6 +357,7 @@
"profile_updating": "Обновление…",
"profile_password_changed_ok": "Пароль успешно изменён.",
"profile_playback_speed": "Скорость воспроизведения — {speed}x",
"profile_subscription_heading": "Подписка",
"profile_plan_pro": "Pro",
"profile_plan_free": "Бесплатно",
@@ -342,7 +369,7 @@
"profile_upgrade_monthly": "Ежемесячно — $6 / мес",
"profile_upgrade_annual": "Ежегодно — $48 / год",
"profile_free_limits": "Бесплатный план: 3 аудиоглавы в день, только английский.",
"subscribe_page_title": "Перейти на Pro libnovel",
"subscribe_page_title": "Перейти на Pro \u2014 libnovel",
"subscribe_heading": "Читайте больше. Слушайте больше.",
"subscribe_subheading": "Перейдите на Pro и откройте полный опыт libnovel.",
"subscribe_monthly_label": "Ежемесячно",
@@ -362,12 +389,14 @@
"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": "Скрейпинг",
@@ -383,6 +412,7 @@
"admin_nav_logs": "Логи",
"admin_nav_uptime": "Мониторинг",
"admin_nav_push": "Уведомления",
"admin_scrape_status_idle": "Ожидание",
"admin_scrape_full_catalogue": "Полный каталог",
"admin_scrape_single_book": "Одна книга",
@@ -393,21 +423,25 @@
"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": "Пауза — управление ниже",
@@ -420,6 +454,7 @@
"reader_voice_applies_next": "Новый голос применится при следующем нажатии «Воспроизвести».",
"reader_choose_voice": "Выбрать голос",
"reader_generating_narration": "Генерация озвучки…",
"profile_font_family": "Шрифт",
"profile_font_system": "Системный",
"profile_font_serif": "Serif",
@@ -429,6 +464,7 @@
"profile_text_size_md": "Нормальный",
"profile_text_size_lg": "Большой",
"profile_text_size_xl": "Очень большой",
"feed_page_title": "Лента — LibNovel",
"feed_heading": "Лента подписок",
"feed_subheading": "Книги, которые читают ваши подписки",
@@ -441,17 +477,19 @@
"feed_find_users_cta": "Найти читателей",
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana",
"admin_translation_page_title": "Translation — Admin",
"admin_translation_page_title": "Translation \u2014 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",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026",
"admin_translation_no_matching": "No matching jobs.",
"admin_translation_no_jobs": "No translation jobs yet.",
"admin_ai_jobs_page_title": "AI Jobs — Admin",
"admin_ai_jobs_page_title": "AI Jobs \u2014 Admin",
"admin_ai_jobs_heading": "AI Jobs",
"admin_ai_jobs_subheading": "Background AI generation tasks",
"admin_text_gen_page_title": "Text Gen — Admin",
"admin_text_gen_heading": "Text Generation",
"admin_nav_import": "Import"
"admin_text_gen_page_title": "Text Gen \u2014 Admin",
"admin_text_gen_heading": "Text Generation"
}

View File

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

View File

@@ -373,7 +373,6 @@ 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'
@@ -406,6 +405,18 @@ 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'
@@ -442,16 +453,4 @@ 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 './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 './feed_find_users_cta.js'

View File

@@ -26,10 +26,6 @@ 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}
@@ -41,4 +37,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,10 +26,6 @@ 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}
@@ -41,4 +37,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,10 +26,6 @@ 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}
@@ -41,4 +37,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

@@ -1,44 +0,0 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Nav_ImportInputs */
const en_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Import`)
};
const ru_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Import`)
};
const id_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Import`)
};
const pt_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Import`)
};
const fr_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Import`)
};
/**
* | output |
* | --- |
* | "Import" |
*
* @param {Admin_Nav_ImportInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_import = /** @type {((inputs?: Admin_Nav_ImportInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_ImportInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_import(inputs)
if (locale === "ru") return ru_admin_nav_import(inputs)
if (locale === "id") return id_admin_nav_import(inputs)
if (locale === "pt") return pt_admin_nav_import(inputs)
return fr_admin_nav_import(inputs)
});

View File

@@ -26,10 +26,6 @@ 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}
@@ -41,4 +37,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,10 +26,6 @@ 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}
@@ -41,4 +37,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,10 +26,6 @@ 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}
@@ -41,4 +37,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,10 +26,6 @@ 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}
@@ -41,4 +37,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,10 +26,6 @@ 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}
@@ -41,4 +37,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,10 +26,6 @@ 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}
@@ -41,4 +37,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,10 +26,6 @@ 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}
@@ -41,4 +37,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,10 +26,6 @@ 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}
@@ -41,4 +37,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,10 +26,6 @@ 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}
@@ -41,4 +37,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

@@ -2287,17 +2287,10 @@ export async function getUserStats(
// ─── AI Jobs ──────────────────────────────────────────────────────────────────
const AI_JOBS_CACHE_KEY = 'admin:ai_jobs';
const AI_JOBS_CACHE_TTL = 30; // 30 seconds — same as other admin job lists
/**
* List all AI jobs from PocketBase, sorted by started descending.
* Short-lived cache (30s) to avoid hammering PocketBase on every navigation.
* No caching — admin views always want fresh data.
*/
export async function listAIJobs(): Promise<AIJob[]> {
const cached = await cache.get<AIJob[]>(AI_JOBS_CACHE_KEY);
if (cached) return cached;
const jobs = await listAll<AIJob>('ai_jobs', '', '-started');
await cache.set(AI_JOBS_CACHE_KEY, jobs, AI_JOBS_CACHE_TTL);
return jobs;
return listAll<AIJob>('ai_jobs', '', '-started');
}

View File

@@ -23,28 +23,6 @@
// 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;
@@ -551,7 +529,7 @@
{#if !/\/books\/[^/]+\/chapters\//.test(page.url.pathname)}
<button
type="button"
onclick={() => { searchOpen = true; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = false; notificationsOpen = false; }}
onclick={() => { searchOpen = true; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = 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)"
@@ -561,43 +539,6 @@
</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

@@ -28,6 +28,21 @@
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(),
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/text-gen',
label: () => m.admin_nav_text_gen(),

View File

@@ -6,8 +6,7 @@ export type { AIJob };
export const load: PageServerLoad = async () => {
// Parent layout already guards admin role.
// Stream jobs so navigation is instant; list populates a moment later.
const jobs = listAIJobs().catch((e): AIJob[] => {
const jobs = await listAIJobs().catch((e): AIJob[] => {
log.warn('admin/ai-jobs', 'failed to load ai jobs', { err: String(e) });
return [];
});

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
import type { AIJob } from '$lib/server/pocketbase';
@@ -7,11 +8,11 @@
let { data }: { data: PageData } = $props();
let jobs = $state<AIJob[]>([]);
let jobs = $state<AIJob[]>(untrack(() => data.jobs));
// Resolve streamed promise on load and on server reloads (invalidateAll)
// Keep in sync on server reloads
$effect(() => {
data.jobs.then((resolved) => { jobs = resolved; });
jobs = data.jobs;
});
// ── Live-poll while any job is in-flight ─────────────────────────────────────

View File

@@ -18,27 +18,23 @@ const CACHE_KEY = 'admin:changelog:releases';
const CACHE_TTL = 5 * 60; // 5 minutes
export const load: PageServerLoad = async ({ fetch }) => {
// Return cached data synchronously (no streaming needed — already fast).
const cached = await cache.get<Release[]>(CACHE_KEY);
if (cached) {
return { releases: cached, error: undefined as string | undefined };
return { releases: cached };
}
// Cache miss: stream the external Gitea request so navigation isn't blocked.
const releasesPromise = (async () => {
try {
const res = await fetch(GITEA_RELEASES_URL, {
headers: { Accept: 'application/json' }
});
if (!res.ok) return [] as Release[];
const releases: Release[] = await res.json();
const filtered = releases.filter((r) => !r.draft);
await cache.set(CACHE_KEY, filtered, CACHE_TTL);
return filtered;
} catch {
return [] as Release[];
try {
const res = await fetch(GITEA_RELEASES_URL, {
headers: { Accept: 'application/json' }
});
if (!res.ok) {
return { releases: [], error: `Gitea API returned ${res.status}` };
}
})();
return { releases: releasesPromise, error: undefined as string | undefined };
const releases: Release[] = await res.json();
const filtered = releases.filter((r) => !r.draft);
await cache.set(CACHE_KEY, filtered, CACHE_TTL);
return { releases: filtered };
} catch (e) {
return { releases: [], error: String(e) };
}
};

View File

@@ -32,33 +32,29 @@
</a>
</div>
{#await data.releases}
<p class="text-sm text-(--color-muted) py-8 text-center">Loading releases…</p>
{:then releases}
{#if releases.length === 0}
<p class="text-sm text-(--color-muted) py-8 text-center">{m.admin_changelog_no_releases()}</p>
{:else}
<div class="space-y-0 divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
{#each releases as release}
<div class="px-5 py-4 bg-(--color-surface) space-y-2">
<div class="flex items-baseline gap-3 flex-wrap">
<span class="font-mono text-sm font-semibold text-(--color-brand)">{release.tag_name}</span>
{#if release.name && release.name !== release.tag_name}
<span class="text-sm text-(--color-text)">{release.name}</span>
{/if}
{#if release.prerelease}
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted)">pre-release</span>
{/if}
<span class="text-xs text-(--color-muted) ml-auto">{fmtDate(release.published_at)}</span>
</div>
{#if release.body.trim()}
<p class="text-sm text-(--color-muted) leading-relaxed whitespace-pre-wrap">{release.body.trim()}</p>
{#if data.error}
<p class="text-sm text-(--color-danger)">{m.admin_changelog_load_error({ error: data.error })}</p>
{:else if data.releases.length === 0}
<p class="text-sm text-(--color-muted) py-8 text-center">{m.admin_changelog_no_releases()}</p>
{:else}
<div class="space-y-0 divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
{#each data.releases as release}
<div class="px-5 py-4 bg-(--color-surface) space-y-2">
<div class="flex items-baseline gap-3 flex-wrap">
<span class="font-mono text-sm font-semibold text-(--color-brand)">{release.tag_name}</span>
{#if release.name && release.name !== release.tag_name}
<span class="text-sm text-(--color-text)">{release.name}</span>
{/if}
{#if release.prerelease}
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted)">pre-release</span>
{/if}
<span class="text-xs text-(--color-muted) ml-auto">{fmtDate(release.published_at)}</span>
</div>
{/each}
</div>
{/if}
{:catch}
<p class="text-sm text-(--color-danger)">{m.admin_changelog_load_error({ error: 'Failed to load releases' })}</p>
{/await}
{#if release.body.trim()}
<p class="text-sm text-(--color-muted) leading-relaxed whitespace-pre-wrap">{release.body.trim()}</p>
{/if}
</div>
{/each}
</div>
{/if}
</div>

View File

@@ -20,29 +20,23 @@ export interface BookSummary {
}
export const load: PageServerLoad = async () => {
// Await models immediately — the page is unusable without them and the
// backend returns this list instantly (in-memory, no I/O).
// Books are streamed: the page renders at once and the book selector
// populates a moment later without blocking navigation.
const modelsResult = await listImageModels<ImageModelInfo>().catch((e) => {
log.warn('admin/image-gen', 'failed to load models', { err: String(e) });
return [] as ImageModelInfo[];
});
// parent layout already guards admin role
const [models, booksResult] = await Promise.allSettled([
listImageModels<ImageModelInfo>(),
listBooks()
]);
const booksPromise = listBooks()
.then((all) =>
all.map((b) => ({
slug: b.slug,
title: b.title,
summary: b.summary ?? '',
cover: b.cover ?? ''
})) as BookSummary[]
)
.catch(() => [] as BookSummary[]);
if (models.status === 'rejected') {
log.warn('admin/image-gen', 'failed to load models', { err: String(models.reason) });
}
return {
models: modelsResult,
// Streamed — SvelteKit resolves this after the initial HTML is sent.
books: booksPromise
models: models.status === 'fulfilled' ? models.value : ([] as ImageModelInfo[]),
books: (booksResult.status === 'fulfilled' ? booksResult.value : []).map((b) => ({
slug: b.slug,
title: b.title,
summary: b.summary ?? '',
cover: b.cover ?? ''
})) as BookSummary[]
};
};

View File

@@ -62,11 +62,8 @@
});
// ── Book autocomplete ────────────────────────────────────────────────────────
// Books arrive as a streamed promise — start empty and populate on resolve.
let books = $state<BookSummary[]>([]);
$effect(() => {
data.books.then((resolved) => { books = resolved; });
});
// svelte-ignore state_referenced_locally
const books: BookSummary[] = data.books ?? [];
let slugInput = $state('');
let slugFocused = $state(false);
let selectedBook = $state<BookSummary | null>(null);

View File

@@ -15,20 +15,11 @@
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;
@@ -45,82 +36,35 @@
}
}
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 handleSubmit(e: Event) {
e.preventDefault();
if (!title.trim()) return;
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
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title })
});
if (res.ok) {
pendingImport = null;
selectedFile = null;
const data = await res.json();
title = '';
await loadTasks();
} else {
const d = await res.json().catch(() => ({}));
error = d.error || 'Import failed';
const data = await res.json();
error = data.error || 'Upload failed';
}
} catch (e) {
error = 'Import failed';
error = 'Upload failed';
} finally {
uploading = false;
}
}
function cancelReview() {
pendingImport = null;
}
function formatDate(dateStr: string) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString();
@@ -139,99 +83,35 @@
onMount(() => {
loadTasks();
});
// Poll every 3s while any task is running
$effect(() => {
const hasRunning = tasks.some((t) => t.status === 'running' || t.status === 'pending');
if (!hasRunning) return;
const timer = setInterval(() => {
loadTasks();
}, 3000);
return () => clearInterval(timer);
});
</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 for="import-file" class="block text-sm font-medium mb-2">Select File (PDF or EPUB)</label>
<input
id="import-file"
type="file"
accept=".pdf,.epub"
onchange={handleFileSelect}
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text)"
/>
</div>
<div class="mb-4">
<label for="import-title" class="block text-sm font-medium mb-2">Book Title</label>
<input
id="import-title"
type="text"
bind:value={title}
placeholder="Enter book title"
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text)"
/>
</div>
{#if error}
<p class="mb-4 text-sm text-red-400">{error}</p>
{/if}
<!-- Upload Form -->
<form onsubmit={handleSubmit} class="mb-8 p-4 bg-(--color-surface-2) rounded-lg">
<div class="flex gap-4">
<input
type="text"
bind:value={title}
placeholder="Book title"
class="flex-1 px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text)"
/>
<button
type="submit"
disabled={analyzing || !selectedFile || !title.trim()}
disabled={uploading || !title.trim()}
class="px-4 py-2 bg-(--color-brand) text-(--color-surface) rounded font-medium disabled:opacity-50"
>
{analyzing ? 'Analyzing...' : 'Review & Import'}
{uploading ? 'Creating...' : 'Import'}
</button>
<p class="mt-2 text-xs text-(--color-muted)">
Select a file to preview chapter count before importing.
</p>
</form>
{/if}
</div>
{#if error}
<p class="mt-2 text-sm text-red-400">{error}</p>
{/if}
<p class="mt-2 text-xs text-(--color-muted)">
Upload a PDF or EPUB file to import chapters. The runner will process the file.
</p>
</form>
<!-- Task List -->
<h2 class="text-lg font-semibold mb-4">Import Tasks</h2>

View File

@@ -17,23 +17,21 @@ export interface TextModelInfo {
}
export const load: PageServerLoad = async () => {
// Await models immediately — in-memory list, no I/O, returns instantly.
// Books are streamed so the page renders at once and the selector
// populates a moment later without blocking navigation.
const modelsResult = await listTextModels<TextModelInfo>().catch((e) => {
log.warn('admin/text-gen', 'failed to load models', { err: String(e) });
return [] as TextModelInfo[];
});
// Parent layout already guards admin role.
const [models, booksResult] = await Promise.allSettled([
listTextModels<TextModelInfo>(),
listBooks()
]);
const booksPromise = listBooks()
.then((all) =>
all.map((b) => ({ slug: b.slug, title: b.title })) as BookSummary[]
)
.catch(() => [] as BookSummary[]);
if (models.status === 'rejected') {
log.warn('admin/text-gen', 'failed to load models', { err: String(models.reason) });
}
return {
models: modelsResult,
// Streamed — SvelteKit resolves this after the initial HTML is sent.
books: booksPromise
models: models.status === 'fulfilled' ? models.value : ([] as TextModelInfo[]),
books: (booksResult.status === 'fulfilled' ? booksResult.value : []).map((b) => ({
slug: b.slug,
title: b.title
})) as BookSummary[]
};
};

View File

@@ -9,11 +9,8 @@
// Server data is static per page load — intentional one-time snapshot.
// svelte-ignore state_referenced_locally
const models: TextModelInfo[] = data.models ?? [];
// Books arrive as a streamed promise — start empty and populate on resolve.
let books = $state<BookSummary[]>([]);
$effect(() => {
data.books.then((resolved) => { books = resolved; });
});
// svelte-ignore state_referenced_locally
const books: BookSummary[] = data.books ?? [];
// ── Config persistence ───────────────────────────────────────────────────────
const CONFIG_KEY = 'admin_text_gen_config_v2';

View File

@@ -17,41 +17,22 @@ export const GET: RequestHandler = async ({ locals }) => {
/**
* POST /api/admin/import
* Create a new import task. Supports both multipart/form-data (file upload)
* and application/json (object key reference).
* Create a new import task.
*/
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const ct = request.headers.get('content-type') ?? '';
let res: Response;
if (ct.includes('multipart/form-data')) {
// Forward the raw FormData body; let the browser-set Content-Type
// (which includes the boundary) pass through unchanged.
const formData = await request.formData();
res = await backendFetch('/api/admin/import', {
method: 'POST',
// Do NOT set Content-Type manually — the fetch API sets it
// automatically with the correct boundary when given a FormData body.
body: formData
});
} else {
const body = await request.json();
res = await backendFetch('/api/admin/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
}
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);
};
};

View File

@@ -1,11 +0,0 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { backendFetch } from '$lib/server/scraper';
export const GET: RequestHandler = async ({ url }) => {
const userId = url.searchParams.get('user_id');
if (!userId) throw error(400, 'user_id required');
const res = await backendFetch('/api/notifications?user_id=' + userId);
const data = await res.json().catch(() => ({ notifications: [] }));
return json(data);
};

View File

@@ -1,19 +0,0 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { backendFetch } from '$lib/server/scraper';
export const GET: RequestHandler = async ({ url }) => {
const userId = url.searchParams.get('user_id');
if (!userId) throw error(400, 'user_id required');
const res = await backendFetch('/api/notifications?user_id=' + userId);
const data = await res.json().catch(() => ({ notifications: [] }));
return json(data);
};
export const PATCH: RequestHandler = async ({ params }) => {
const id = params.id;
if (!id) throw error(400, 'id required');
const res = await backendFetch('/api/notifications/' + id, { method: 'PATCH' });
const data = await res.json().catch(() => ({}));
return json(data);
};