Compare commits

...

7 Commits

Author SHA1 Message Date
root
e088bc056e feat: add PDF/EPUB import functionality
Some checks failed
Release / Test backend (push) Successful in 43s
Release / Check ui (push) Failing after 35s
Release / Docker (push) Has been skipped
Release / Gitea Release (push) Has been skipped
- Add ImportTask/ImportResult types to domain.go
- Add TypeImportBook to asynqqueue for task routing
- Add CreateImportTask to producer and storage layers
- Add ClaimNextImportTask/FinishImportTask to Consumer interfaces
- Add import task handling to runner (polling + Asynq handler)
- Add BookImporter interface to bookstore for PDF/EPUB parsing
- Add backend API endpoints: POST/GET /api/admin/import
- Add SvelteKit UI at /admin/import with task list
- Add nav link in admin layout

Note: PDF/EPUB parsing is a placeholder - needs external library integration.
2026-04-09 10:01:20 +05:00
root
a904ff4e21 fix: update CF AI image gen to use multipart for FLUX.2 models
All checks were successful
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Successful in 1m41s
Release / Docker (push) Successful in 5m39s
Release / Gitea Release (push) Successful in 30s
Cloudflare Workers AI changed the API for flux-2-dev, flux-2-klein-4b,
and flux-2-klein-9b to require multipart/form-data (instead of JSON) and
now returns {"image":"<base64>"} instead of raw PNG bytes.

- Add requiresMultipart() helper for the three FLUX.2 models
- callImageAPI builds multipart body for those models, JSON for others
- Parse {"image":"<base64>"} JSON response; fall back to raw bytes for legacy models
- Use "steps" field name (not "num_steps") in multipart forms per CF docs
- Book page: capture and display actual backend error message instead of blank 'Error'
2026-04-08 22:28:36 +05:00
root
04e63414a3 fix: restore swipeStartX declaration (newline eaten in prior edit)
All checks were successful
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Successful in 1m41s
Release / Docker (push) Successful in 5m34s
Release / Gitea Release (push) Successful in 33s
2026-04-08 21:23:23 +05:00
root
bae363893b fix: include generated id in createComment POST body
Some checks failed
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Failing after 32s
Release / Docker (push) Has been skipped
Release / Gitea Release (push) Has been skipped
The book_comments collection has a custom 'id' field (type: text,
required: true) distinct from PocketBase's system record ID.
Every comment POST was returning 400 validation_required because
the id field was never sent.

Fix: generate a 15-char hex ID via crypto.randomUUID() and include
it in the payload, matching PocketBase's own ID alphabet.
2026-04-08 21:06:23 +05:00
root
b7306877f1 refactor: clean up home page UI
Some checks failed
Release / Test backend (push) Successful in 43s
Release / Check ui (push) Failing after 36s
Release / Docker (push) Has been skipped
Release / Gitea Release (push) Has been skipped
- Remove all emojis (flame icon in streak widget, 🔥 in stats footer,
  ✓ in completed badge) — they cheapened the overall feel
- Fix double carousel indicator: drop the animated progress sub-line
  below the active dot; the expanding pill shape is sufficient signal.
  Also removes the rAF animation loop and progressStart state.
- Remove 'X left' badge from Continue Reading shelf cards
- Remove 'X chapters ahead' text from hero card info row
2026-04-08 20:54:37 +05:00
root
0723049e0c ci: fail if Paraglide generated files are out of sync with messages JSON
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 1m46s
Release / Docker (push) Successful in 5m39s
Release / Gitea Release (push) Successful in 29s
2026-04-08 20:42:21 +05:00
root
b206994459 fix: generate missing Paraglide message modules for new i18n keys
Hand-authored the 12 missing .js message modules and updated _index.js
since npm/paraglide-js codegen cannot run in this environment.
These are equivalent to what 'npm run paraglide' would generate.

Going forward: run 'npm run paraglide' after adding keys to messages/*.json.
2026-04-08 20:40:58 +05:00
35 changed files with 1599 additions and 124 deletions

View File

@@ -63,6 +63,9 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Check Paraglide codegen is up to date
run: npm run paraglide && git diff --exit-code src/lib/paraglide/
- name: Type check
run: npm run check

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ const (
TypeAudioGenerate = "audio:generate"
TypeScrapeBook = "scrape:book"
TypeScrapeCatalogue = "scrape:catalogue"
TypeImportBook = "import:book"
)
// AudioPayload is the Asynq job payload for audio generation tasks.
@@ -44,3 +45,12 @@ type ScrapePayload struct {
FromChapter int `json:"from_chapter"` // 0 unless Kind=="book_range"
ToChapter int `json:"to_chapter"` // 0 unless Kind=="book_range"
}
// ImportPayload is the Asynq job payload for PDF/EPUB import tasks.
type ImportPayload struct {
PBTaskID string `json:"pb_task_id"`
Slug string `json:"slug"`
Title string `json:"title"`
FileType string `json:"file_type"` // "pdf" or "epub"
ObjectKey string `json:"object_key"` // MinIO path to uploaded file
}

View File

@@ -0,0 +1,141 @@
package backend
import (
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/libnovel/backend/internal/storage"
)
type importRequest struct {
Title string `json:"title"`
FileName string `json:"file_name"`
FileType string `json:"file_type"` // "pdf" or "epub"
ObjectKey string `json:"object_key"` // MinIO path to uploaded file
}
type importResponse struct {
TaskID string `json:"task_id"`
Slug string `json:"slug"`
}
func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
if s.deps.Producer == nil {
jsonError(w, http.StatusServiceUnavailable, "task queue not configured")
return
}
ct := r.Header.Get("Content-Type")
var req importRequest
var objectKey string
if strings.HasPrefix(ct, "multipart/form-data") {
if err := r.ParseMultipartForm(32 << 20); err != nil {
jsonError(w, http.StatusBadRequest, "parse multipart: "+err.Error())
return
}
req.Title = r.FormValue("title")
req.FileName = r.FormValue("file_name")
req.FileType = r.FormValue("file_type")
file, header, err := r.FormFile("file")
if err != nil {
jsonError(w, http.StatusBadRequest, "parse file: "+err.Error())
return
}
defer file.Close()
if req.FileName == "" {
req.FileName = header.Filename
}
if req.FileType == "" {
req.FileType = strings.TrimPrefix(filepath.Ext(header.Filename), ".")
}
data, err := io.ReadAll(file)
if err != nil {
jsonError(w, http.StatusBadRequest, "read file: "+err.Error())
return
}
// Upload to MinIO directly via the store
objectKey = fmt.Sprintf("imports/%d_%s", time.Now().Unix(), header.Filename)
store, ok := s.deps.Producer.(*storage.Store)
if !ok {
jsonError(w, http.StatusInternalServerError, "storage not available")
return
}
if err := store.PutImportFile(r.Context(), objectKey, data); err != nil {
jsonError(w, http.StatusInternalServerError, "upload file: "+err.Error())
return
}
} else {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
objectKey = req.ObjectKey
}
if req.Title == "" {
jsonError(w, http.StatusBadRequest, "title is required")
return
}
if req.FileType != "pdf" && req.FileType != "epub" {
jsonError(w, http.StatusBadRequest, "file_type must be 'pdf' or 'epub'")
return
}
slug := strings.ToLower(strings.ReplaceAll(req.Title, " ", "-"))
slug = strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
return r
}
return -1
}, slug)
taskID, err := s.deps.Producer.CreateImportTask(r.Context(), slug, req.Title, req.FileType, objectKey)
if err != nil {
jsonError(w, http.StatusInternalServerError, "create import task: "+err.Error())
return
}
writeJSON(w, 0, importResponse{
TaskID: taskID,
Slug: slug,
})
}
func (s *Server) handleAdminImportStatus(w http.ResponseWriter, r *http.Request) {
taskID := r.PathValue("id")
if taskID == "" {
jsonError(w, http.StatusBadRequest, "task id required")
return
}
task, ok, err := s.deps.TaskReader.GetImportTask(r.Context(), taskID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "get task: "+err.Error())
return
}
if !ok {
jsonError(w, http.StatusNotFound, "task not found")
return
}
writeJSON(w, 0, task)
}
func (s *Server) handleAdminImportList(w http.ResponseWriter, r *http.Request) {
tasks, err := s.deps.TaskReader.ListImportTasks(r.Context())
if err != nil {
jsonError(w, http.StatusInternalServerError, "list tasks: "+err.Error())
return
}
writeJSON(w, 0, map[string]any{"tasks": tasks})
}

View File

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

View File

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

View File

@@ -4,17 +4,26 @@
//
// POST https://api.cloudflare.com/client/v4/accounts/{accountID}/ai/run/{model}
// Authorization: Bearer {apiToken}
// Content-Type: application/json
//
// Text-only request (all models):
// FLUX.2 models (flux-2-dev, flux-2-klein-4b, flux-2-klein-9b):
//
// { "prompt": "...", "num_steps": 20 }
// Content-Type: multipart/form-data
// Fields: prompt, num_steps, width, height, guidance, image_b64 (optional)
// Response: { "image": "<base64 JPEG>" }
//
// Reference-image request:
// - FLUX models: { "prompt": "...", "image_b64": "<base64>" }
// - SD img2img: { "prompt": "...", "image": [r,g,b,a,...], "strength": 0.75 }
// Other models (flux-1-schnell, SDXL, SD 1.5):
//
// All models return raw PNG bytes on success (Content-Type: image/png).
// Content-Type: application/json
// Body: { "prompt": "...", "num_steps": 20 }
// Response: { "image": "<base64>" } or raw bytes depending on model
//
// Reference-image request (FLUX.2):
//
// Same multipart form; include image_b64 field with base64-encoded reference.
//
// Reference-image request (SD img2img):
//
// JSON body: { "prompt": "...", "image": [r,g,b,a,...], "strength": 0.75 }
//
// Recommended models for LibNovel:
// - Book covers (no reference): flux-2-dev, flux-2-klein-9b, lucid-origin
@@ -35,7 +44,9 @@ import (
"image/png"
_ "image/png" // register PNG decoder
"io"
"mime/multipart"
"net/http"
"strings"
"time"
)
@@ -173,23 +184,43 @@ func NewImageGen(accountID, apiToken string) ImageGenClient {
}
}
// requiresMultipart reports whether the model requires a multipart/form-data
// request body instead of JSON. FLUX.2 models on Cloudflare Workers AI changed
// their API to require multipart and return {"image":"<base64>"} instead of
// raw image bytes.
func requiresMultipart(model ImageModel) bool {
switch model {
case ImageModelFlux2Dev, ImageModelFlux2Klein4B, ImageModelFlux2Klein9B:
return true
default:
return false
}
}
// GenerateImage generates an image from text only.
func (c *imageGenHTTPClient) GenerateImage(ctx context.Context, req ImageRequest) ([]byte, error) {
req = applyImageDefaults(req)
body := map[string]any{
"prompt": req.Prompt,
"num_steps": req.NumSteps,
// FLUX.2 multipart models use "steps"; JSON models use "num_steps".
stepsKey := "num_steps"
if requiresMultipart(req.Model) {
stepsKey = "steps"
}
fields := map[string]any{
"prompt": req.Prompt,
stepsKey: req.NumSteps,
}
if req.Width > 0 {
body["width"] = req.Width
fields["width"] = req.Width
}
if req.Height > 0 {
body["height"] = req.Height
fields["height"] = req.Height
}
if req.Guidance > 0 {
body["guidance"] = req.Guidance
fields["guidance"] = req.Guidance
}
return c.callImageAPI(ctx, req.Model, body)
return c.callImageAPI(ctx, req.Model, fields, nil)
}
// refImageMaxDim is the maximum dimension (width or height) for reference images
@@ -205,10 +236,37 @@ func (c *imageGenHTTPClient) GenerateImageFromReference(ctx context.Context, req
req = applyImageDefaults(req)
// Shrink the reference image if it exceeds the safe payload size.
// This avoids CF's 4 MB JSON body limit and reduces latency.
refImage = resizeRefImage(refImage, refImageMaxDim)
var body map[string]any
// FLUX.2 multipart models use "steps"; JSON models use "num_steps".
stepsKey := "num_steps"
if requiresMultipart(req.Model) {
stepsKey = "steps"
}
fields := map[string]any{
"prompt": req.Prompt,
stepsKey: req.NumSteps,
}
if req.Width > 0 {
fields["width"] = req.Width
}
if req.Height > 0 {
fields["height"] = req.Height
}
if req.Guidance > 0 {
fields["guidance"] = req.Guidance
}
if requiresMultipart(req.Model) {
// FLUX.2: reference image sent as base64 form field "image_b64".
fields["image_b64"] = base64.StdEncoding.EncodeToString(refImage)
if req.Strength > 0 {
fields["strength"] = req.Strength
}
return c.callImageAPI(ctx, req.Model, fields, nil)
}
if req.Model == ImageModelSD15Img2Img {
pixels, err := decodeImageToRGBA(refImage)
if err != nil {
@@ -218,33 +276,17 @@ func (c *imageGenHTTPClient) GenerateImageFromReference(ctx context.Context, req
if strength <= 0 {
strength = 0.75
}
body = map[string]any{
"prompt": req.Prompt,
"image": pixels,
"strength": strength,
"num_steps": req.NumSteps,
}
} else {
b64 := base64.StdEncoding.EncodeToString(refImage)
body = map[string]any{
"prompt": req.Prompt,
"image_b64": b64,
"num_steps": req.NumSteps,
}
if req.Strength > 0 {
body["strength"] = req.Strength
}
fields["image"] = pixels
fields["strength"] = strength
return c.callImageAPI(ctx, req.Model, fields, nil)
}
if req.Width > 0 {
body["width"] = req.Width
// Other FLUX models: image_b64 JSON field.
fields["image_b64"] = base64.StdEncoding.EncodeToString(refImage)
if req.Strength > 0 {
fields["strength"] = req.Strength
}
if req.Height > 0 {
body["height"] = req.Height
}
if req.Guidance > 0 {
body["guidance"] = req.Guidance
}
return c.callImageAPI(ctx, req.Model, body)
return c.callImageAPI(ctx, req.Model, fields, nil)
}
// Models returns all supported image model metadata.
@@ -252,19 +294,56 @@ func (c *imageGenHTTPClient) Models() []ImageModelInfo {
return AllImageModels()
}
func (c *imageGenHTTPClient) callImageAPI(ctx context.Context, model ImageModel, body map[string]any) ([]byte, error) {
encoded, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("cfai/image: marshal: %w", err)
}
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s",
func (c *imageGenHTTPClient) callImageAPI(ctx context.Context, model ImageModel, fields map[string]any, _ []byte) ([]byte, error) {
cfURL := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s",
c.accountID, string(model))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(encoded))
var (
bodyReader io.Reader
contentType string
)
if requiresMultipart(model) {
// Build a multipart/form-data body from the fields map.
// All values are serialised to their string representation.
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
for k, v := range fields {
var strVal string
switch tv := v.(type) {
case string:
strVal = tv
default:
encoded, merr := json.Marshal(tv)
if merr != nil {
return nil, fmt.Errorf("cfai/image: marshal field %q: %w", k, merr)
}
strVal = strings.Trim(string(encoded), `"`)
}
if werr := mw.WriteField(k, strVal); werr != nil {
return nil, fmt.Errorf("cfai/image: write field %q: %w", k, werr)
}
}
if cerr := mw.Close(); cerr != nil {
return nil, fmt.Errorf("cfai/image: close multipart writer: %w", cerr)
}
bodyReader = &buf
contentType = mw.FormDataContentType()
} else {
encoded, merr := json.Marshal(fields)
if merr != nil {
return nil, fmt.Errorf("cfai/image: marshal: %w", merr)
}
bodyReader = bytes.NewReader(encoded)
contentType = "application/json"
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfURL, bodyReader)
if err != nil {
return nil, fmt.Errorf("cfai/image: build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Content-Type", contentType)
resp, err := c.http.Do(req)
if err != nil {
@@ -272,20 +351,38 @@ func (c *imageGenHTTPClient) callImageAPI(ctx context.Context, model ImageModel,
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("cfai/image: read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
errBody, _ := io.ReadAll(resp.Body)
msg := string(errBody)
msg := string(respBody)
if len(msg) > 300 {
msg = msg[:300]
}
return nil, fmt.Errorf("cfai/image: model %s returned %d: %s", model, resp.StatusCode, msg)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("cfai/image: read response: %w", err)
// Try to parse as {"image": "<base64>"} first (FLUX.2 and newer models).
// Fall back to treating the body as raw image bytes for legacy models.
var jsonResp struct {
Image string `json:"image"`
}
return data, nil
if jerr := json.Unmarshal(respBody, &jsonResp); jerr == nil && jsonResp.Image != "" {
imgBytes, decErr := base64.StdEncoding.DecodeString(jsonResp.Image)
if decErr != nil {
// Try raw (no padding) base64
imgBytes, decErr = base64.RawStdEncoding.DecodeString(jsonResp.Image)
if decErr != nil {
return nil, fmt.Errorf("cfai/image: decode base64 response: %w", decErr)
}
}
return imgBytes, nil
}
// Legacy: model returned raw image bytes directly.
return respBody, nil
}
func applyImageDefaults(req ImageRequest) ImageRequest {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -405,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'

View File

@@ -0,0 +1,40 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Ai_Jobs_HeadingInputs */
const en_admin_ai_jobs_heading = /** @type {(inputs: Admin_Ai_Jobs_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`AI Jobs`)
};
const ru_admin_ai_jobs_heading = /** @type {(inputs: Admin_Ai_Jobs_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`AI Jobs`)
};
const id_admin_ai_jobs_heading = /** @type {(inputs: Admin_Ai_Jobs_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`AI Jobs`)
};
const pt_admin_ai_jobs_heading = /** @type {(inputs: Admin_Ai_Jobs_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`AI Jobs`)
};
const fr_admin_ai_jobs_heading = /** @type {(inputs: Admin_Ai_Jobs_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`AI Jobs`)
};
/**
* @param {Admin_Ai_Jobs_HeadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_ai_jobs_heading = /** @type {((inputs?: Admin_Ai_Jobs_HeadingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Ai_Jobs_HeadingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_ai_jobs_heading(inputs)
if (locale === "ru") return ru_admin_ai_jobs_heading(inputs)
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

@@ -0,0 +1,40 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Ai_Jobs_Page_TitleInputs */
const en_admin_ai_jobs_page_title = /** @type {(inputs: Admin_Ai_Jobs_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`AI Jobs — Admin`)
};
const ru_admin_ai_jobs_page_title = /** @type {(inputs: Admin_Ai_Jobs_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`AI Jobs — Admin`)
};
const id_admin_ai_jobs_page_title = /** @type {(inputs: Admin_Ai_Jobs_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`AI Jobs — Admin`)
};
const pt_admin_ai_jobs_page_title = /** @type {(inputs: Admin_Ai_Jobs_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`AI Jobs — Admin`)
};
const fr_admin_ai_jobs_page_title = /** @type {(inputs: Admin_Ai_Jobs_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`AI Jobs — Admin`)
};
/**
* @param {Admin_Ai_Jobs_Page_TitleInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_ai_jobs_page_title = /** @type {((inputs?: Admin_Ai_Jobs_Page_TitleInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Ai_Jobs_Page_TitleInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_ai_jobs_page_title(inputs)
if (locale === "ru") return ru_admin_ai_jobs_page_title(inputs)
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

@@ -0,0 +1,40 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Ai_Jobs_SubheadingInputs */
const en_admin_ai_jobs_subheading = /** @type {(inputs: Admin_Ai_Jobs_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Background AI generation tasks`)
};
const ru_admin_ai_jobs_subheading = /** @type {(inputs: Admin_Ai_Jobs_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Background AI generation tasks`)
};
const id_admin_ai_jobs_subheading = /** @type {(inputs: Admin_Ai_Jobs_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Background AI generation tasks`)
};
const pt_admin_ai_jobs_subheading = /** @type {(inputs: Admin_Ai_Jobs_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Background AI generation tasks`)
};
const fr_admin_ai_jobs_subheading = /** @type {(inputs: Admin_Ai_Jobs_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Background AI generation tasks`)
};
/**
* @param {Admin_Ai_Jobs_SubheadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_ai_jobs_subheading = /** @type {((inputs?: Admin_Ai_Jobs_SubheadingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Ai_Jobs_SubheadingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_ai_jobs_subheading(inputs)
if (locale === "ru") return ru_admin_ai_jobs_subheading(inputs)
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

@@ -0,0 +1,40 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Text_Gen_HeadingInputs */
const en_admin_text_gen_heading = /** @type {(inputs: Admin_Text_Gen_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Text Generation`)
};
const ru_admin_text_gen_heading = /** @type {(inputs: Admin_Text_Gen_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Text Generation`)
};
const id_admin_text_gen_heading = /** @type {(inputs: Admin_Text_Gen_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Text Generation`)
};
const pt_admin_text_gen_heading = /** @type {(inputs: Admin_Text_Gen_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Text Generation`)
};
const fr_admin_text_gen_heading = /** @type {(inputs: Admin_Text_Gen_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Text Generation`)
};
/**
* @param {Admin_Text_Gen_HeadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_text_gen_heading = /** @type {((inputs?: Admin_Text_Gen_HeadingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Text_Gen_HeadingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_text_gen_heading(inputs)
if (locale === "ru") return ru_admin_text_gen_heading(inputs)
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

@@ -0,0 +1,40 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Text_Gen_Page_TitleInputs */
const en_admin_text_gen_page_title = /** @type {(inputs: Admin_Text_Gen_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Text Gen — Admin`)
};
const ru_admin_text_gen_page_title = /** @type {(inputs: Admin_Text_Gen_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Text Gen — Admin`)
};
const id_admin_text_gen_page_title = /** @type {(inputs: Admin_Text_Gen_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Text Gen — Admin`)
};
const pt_admin_text_gen_page_title = /** @type {(inputs: Admin_Text_Gen_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Text Gen — Admin`)
};
const fr_admin_text_gen_page_title = /** @type {(inputs: Admin_Text_Gen_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Text Gen — Admin`)
};
/**
* @param {Admin_Text_Gen_Page_TitleInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_text_gen_page_title = /** @type {((inputs?: Admin_Text_Gen_Page_TitleInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Text_Gen_Page_TitleInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_text_gen_page_title(inputs)
if (locale === "ru") return ru_admin_text_gen_page_title(inputs)
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

@@ -0,0 +1,40 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Translation_Filter_PlaceholderInputs */
const en_admin_translation_filter_placeholder = /** @type {(inputs: Admin_Translation_Filter_PlaceholderInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Filter by slug, lang, or status…`)
};
const ru_admin_translation_filter_placeholder = /** @type {(inputs: Admin_Translation_Filter_PlaceholderInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Filter by slug, lang, or status…`)
};
const id_admin_translation_filter_placeholder = /** @type {(inputs: Admin_Translation_Filter_PlaceholderInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Filter by slug, lang, or status…`)
};
const pt_admin_translation_filter_placeholder = /** @type {(inputs: Admin_Translation_Filter_PlaceholderInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Filter by slug, lang, or status…`)
};
const fr_admin_translation_filter_placeholder = /** @type {(inputs: Admin_Translation_Filter_PlaceholderInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Filter by slug, lang, or status…`)
};
/**
* @param {Admin_Translation_Filter_PlaceholderInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_translation_filter_placeholder = /** @type {((inputs?: Admin_Translation_Filter_PlaceholderInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Translation_Filter_PlaceholderInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_translation_filter_placeholder(inputs)
if (locale === "ru") return ru_admin_translation_filter_placeholder(inputs)
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

@@ -0,0 +1,40 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Translation_HeadingInputs */
const en_admin_translation_heading = /** @type {(inputs: Admin_Translation_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Machine Translation`)
};
const ru_admin_translation_heading = /** @type {(inputs: Admin_Translation_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Machine Translation`)
};
const id_admin_translation_heading = /** @type {(inputs: Admin_Translation_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Machine Translation`)
};
const pt_admin_translation_heading = /** @type {(inputs: Admin_Translation_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Machine Translation`)
};
const fr_admin_translation_heading = /** @type {(inputs: Admin_Translation_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Machine Translation`)
};
/**
* @param {Admin_Translation_HeadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_translation_heading = /** @type {((inputs?: Admin_Translation_HeadingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Translation_HeadingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_translation_heading(inputs)
if (locale === "ru") return ru_admin_translation_heading(inputs)
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

@@ -0,0 +1,40 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Translation_No_JobsInputs */
const en_admin_translation_no_jobs = /** @type {(inputs: Admin_Translation_No_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`No translation jobs yet.`)
};
const ru_admin_translation_no_jobs = /** @type {(inputs: Admin_Translation_No_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`No translation jobs yet.`)
};
const id_admin_translation_no_jobs = /** @type {(inputs: Admin_Translation_No_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`No translation jobs yet.`)
};
const pt_admin_translation_no_jobs = /** @type {(inputs: Admin_Translation_No_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`No translation jobs yet.`)
};
const fr_admin_translation_no_jobs = /** @type {(inputs: Admin_Translation_No_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`No translation jobs yet.`)
};
/**
* @param {Admin_Translation_No_JobsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_translation_no_jobs = /** @type {((inputs?: Admin_Translation_No_JobsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Translation_No_JobsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_translation_no_jobs(inputs)
if (locale === "ru") return ru_admin_translation_no_jobs(inputs)
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

@@ -0,0 +1,40 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Translation_No_MatchingInputs */
const en_admin_translation_no_matching = /** @type {(inputs: Admin_Translation_No_MatchingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`No matching jobs.`)
};
const ru_admin_translation_no_matching = /** @type {(inputs: Admin_Translation_No_MatchingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`No matching jobs.`)
};
const id_admin_translation_no_matching = /** @type {(inputs: Admin_Translation_No_MatchingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`No matching jobs.`)
};
const pt_admin_translation_no_matching = /** @type {(inputs: Admin_Translation_No_MatchingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`No matching jobs.`)
};
const fr_admin_translation_no_matching = /** @type {(inputs: Admin_Translation_No_MatchingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`No matching jobs.`)
};
/**
* @param {Admin_Translation_No_MatchingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_translation_no_matching = /** @type {((inputs?: Admin_Translation_No_MatchingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Translation_No_MatchingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_translation_no_matching(inputs)
if (locale === "ru") return ru_admin_translation_no_matching(inputs)
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

@@ -0,0 +1,40 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Translation_Page_TitleInputs */
const en_admin_translation_page_title = /** @type {(inputs: Admin_Translation_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Translation — Admin`)
};
const ru_admin_translation_page_title = /** @type {(inputs: Admin_Translation_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Translation — Admin`)
};
const id_admin_translation_page_title = /** @type {(inputs: Admin_Translation_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Translation — Admin`)
};
const pt_admin_translation_page_title = /** @type {(inputs: Admin_Translation_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Translation — Admin`)
};
const fr_admin_translation_page_title = /** @type {(inputs: Admin_Translation_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Translation — Admin`)
};
/**
* @param {Admin_Translation_Page_TitleInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_translation_page_title = /** @type {((inputs?: Admin_Translation_Page_TitleInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Translation_Page_TitleInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_translation_page_title(inputs)
if (locale === "ru") return ru_admin_translation_page_title(inputs)
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

@@ -0,0 +1,40 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Translation_Tab_EnqueueInputs */
const en_admin_translation_tab_enqueue = /** @type {(inputs: Admin_Translation_Tab_EnqueueInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Enqueue`)
};
const ru_admin_translation_tab_enqueue = /** @type {(inputs: Admin_Translation_Tab_EnqueueInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Enqueue`)
};
const id_admin_translation_tab_enqueue = /** @type {(inputs: Admin_Translation_Tab_EnqueueInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Enqueue`)
};
const pt_admin_translation_tab_enqueue = /** @type {(inputs: Admin_Translation_Tab_EnqueueInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Enqueue`)
};
const fr_admin_translation_tab_enqueue = /** @type {(inputs: Admin_Translation_Tab_EnqueueInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Enqueue`)
};
/**
* @param {Admin_Translation_Tab_EnqueueInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_translation_tab_enqueue = /** @type {((inputs?: Admin_Translation_Tab_EnqueueInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Translation_Tab_EnqueueInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_translation_tab_enqueue(inputs)
if (locale === "ru") return ru_admin_translation_tab_enqueue(inputs)
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

@@ -0,0 +1,40 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Translation_Tab_JobsInputs */
const en_admin_translation_tab_jobs = /** @type {(inputs: Admin_Translation_Tab_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Jobs`)
};
const ru_admin_translation_tab_jobs = /** @type {(inputs: Admin_Translation_Tab_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Jobs`)
};
const id_admin_translation_tab_jobs = /** @type {(inputs: Admin_Translation_Tab_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Jobs`)
};
const pt_admin_translation_tab_jobs = /** @type {(inputs: Admin_Translation_Tab_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Jobs`)
};
const fr_admin_translation_tab_jobs = /** @type {(inputs: Admin_Translation_Tab_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Jobs`)
};
/**
* @param {Admin_Translation_Tab_JobsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_translation_tab_jobs = /** @type {((inputs?: Admin_Translation_Tab_JobsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Translation_Tab_JobsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_translation_tab_jobs(inputs)
if (locale === "ru") return ru_admin_translation_tab_jobs(inputs)
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

@@ -1530,6 +1530,7 @@ export async function createComment(
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
id: crypto.randomUUID().replace(/-/g, '').slice(0, 15),
slug,
chapter,
body,

View File

@@ -90,17 +90,13 @@
// Auto-advance carousel every CAROUSEL_INTERVAL ms when there are multiple books.
// autoAdvanceSeed is bumped on manual swipe/dot to restart the interval.
let autoAdvanceSeed = $state(0);
// progressStart tracks when the current interval began (for the progress bar).
let progressStart = $state(browser ? performance.now() : 0);
$effect(() => {
if (heroBooks.length <= 1) return;
const len = heroBooks.length;
void autoAdvanceSeed; // restart when seed changes
progressStart = browser ? performance.now() : 0;
const id = setInterval(() => {
heroIndex = (heroIndex + 1) % len;
progressStart = browser ? performance.now() : 0;
}, CAROUSEL_INTERVAL);
return () => clearInterval(id);
});
@@ -127,22 +123,6 @@
resetAutoAdvance();
}
// ── Progress bar animation ───────────────────────────────────────────────
// rAF loop drives a 0→1 progress value that resets on each advance.
let rafProgress = $state(0);
$effect(() => {
if (!browser || heroBooks.length <= 1) return;
void autoAdvanceSeed; // re-subscribe so effect re-runs on manual nav
void heroIndex;
let raf: number;
function tick() {
rafProgress = Math.min((performance.now() - progressStart) / CAROUSEL_INTERVAL, 1);
raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
});
function playChapter(slug: string, chapter: number) {
audioStore.autoStartChapter = chapter;
goto(`/books/${slug}/chapters/${chapter}`);
@@ -210,10 +190,6 @@
</svg>
Listen
</button>
{#if heroBook.book.total_chapters > 0 && heroBook.chapter < heroBook.book.total_chapters}
{@const ahead = heroBook.book.total_chapters - heroBook.chapter}
<span class="text-xs text-(--color-muted) hidden sm:inline">{ahead} chapters ahead</span>
{/if}
{#each parseGenres(heroBook.book.genres).slice(0, 2) as genre}
<span class="text-xs px-2 py-1 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
{/each}
@@ -221,7 +197,7 @@
</div>
</div>
<!-- Dot indicators with animated progress line under active dot -->
<!-- Dot indicators -->
{#if heroBooks.length > 1}
<div class="flex items-center justify-center gap-2 mt-2.5">
{#each heroBooks as _, i}
@@ -229,21 +205,10 @@
type="button"
onclick={() => heroDot(i)}
aria-label="Go to book {i + 1}"
class="relative flex flex-col items-center gap-0.5 group/dot"
>
<!-- dot -->
<span class="block rounded-full transition-all duration-300 {i === heroIndex
? 'w-4 h-1.5 bg-(--color-brand)'
: 'w-1.5 h-1.5 bg-(--color-border) group-hover/dot:bg-(--color-muted)'}"></span>
<!-- progress line — only visible under the active dot -->
{#if i === heroIndex}
<span class="absolute -bottom-1.5 left-0 h-0.5 w-full bg-(--color-border) rounded-full overflow-hidden">
<span
class="block h-full bg-(--color-brand) rounded-full"
style="width: {rafProgress * 100}%"
></span>
</span>
{/if}
: 'w-1.5 h-1.5 bg-(--color-border) hover:bg-(--color-muted)'}"></span>
</button>
{/each}
</div>
@@ -256,9 +221,6 @@
{#if streak > 0}
<div class="mb-6 flex items-center gap-3 flex-wrap text-sm">
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border)">
<svg class="w-4 h-4 text-orange-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M13.5 0.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67zM11.71 19c-1.78 0-3.22-1.4-3.22-3.14 0-1.62 1.05-2.76 2.81-3.12 1.77-.36 3.6-1.21 4.62-2.58.39 1.29.59 2.65.59 4.04 0 2.65-2.15 4.8-4.8 4.8z"/>
</svg>
<span class="font-semibold text-(--color-text)">{streak}</span>
<span class="text-(--color-muted)">day{streak !== 1 ? 's' : ''} reading</span>
</span>
@@ -293,12 +255,6 @@
<span class="absolute bottom-1.5 right-1.5 text-xs bg-(--color-brand) text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
{m.home_chapter_badge({ n: String(chapter) })}
</span>
<!-- Chapters ahead badge -->
{#if book.total_chapters > 0 && chapter < book.total_chapters}
<span class="absolute top-1.5 left-1.5 text-xs bg-black/60 text-white font-medium px-1.5 py-0.5 rounded">
{book.total_chapters - chapter} left
</span>
{/if}
</div>
</a>
<!-- Listen button (hover overlay) -->
@@ -338,7 +294,7 @@
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
</div>
{/if}
<span class="absolute top-1.5 right-1.5 text-xs bg-green-600/90 text-white font-bold px-1.5 py-0.5 rounded">Done</span>
<span class="absolute top-1.5 right-1.5 text-xs bg-green-600/90 text-white font-bold px-1.5 py-0.5 rounded">Done</span>
</div>
<div class="p-2">
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
@@ -597,6 +553,6 @@
<span><span class="font-semibold text-(--color-text)">{data.stats.totalChapters.toLocaleString()}</span> {m.home_stat_chapters()}</span>
{#if streak > 0}
<span class="w-px h-4 bg-(--color-border)"></span>
<span><span class="font-semibold text-(--color-text)">{streak}</span> day streak 🔥</span>
<span><span class="font-semibold text-(--color-text)">{streak}</span> day streak</span>
{/if}
</div>

View File

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

View File

@@ -0,0 +1,154 @@
<script lang="ts">
import { onMount } from 'svelte';
interface ImportTask {
id: string;
slug: string;
title: string;
file_name: string;
file_type: string;
status: string;
chapters_done: number;
chapters_total: number;
error_message: string;
started: string;
finished: string;
}
let tasks = $state<ImportTask[]>([]);
let loading = $state(true);
let uploading = $state(false);
let title = $state('');
let error = $state('');
async function loadTasks() {
loading = true;
try {
const res = await fetch('/api/admin/import');
if (res.ok) {
const data = await res.json();
tasks = data.tasks || [];
}
} catch (e) {
console.error('Failed to load tasks:', e);
} finally {
loading = false;
}
}
async function handleSubmit(e: Event) {
e.preventDefault();
if (!title.trim()) return;
uploading = true;
error = '';
try {
const res = await fetch('/api/admin/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title })
});
if (res.ok) {
const data = await res.json();
title = '';
await loadTasks();
} else {
const data = await res.json();
error = data.error || 'Upload failed';
}
} catch (e) {
error = 'Upload failed';
} finally {
uploading = false;
}
}
function formatDate(dateStr: string) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString();
}
function getStatusColor(status: string) {
switch (status) {
case 'pending': return 'text-yellow-400';
case 'running': return 'text-blue-400';
case 'done': return 'text-green-400';
case 'failed': return 'text-red-400';
default: return 'text-gray-400';
}
}
onMount(() => {
loadTasks();
});
</script>
<div class="max-w-4xl">
<h1 class="text-2xl font-bold mb-6">Import PDF/EPUB</h1>
<!-- 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={uploading || !title.trim()}
class="px-4 py-2 bg-(--color-brand) text-(--color-surface) rounded font-medium disabled:opacity-50"
>
{uploading ? 'Creating...' : 'Import'}
</button>
</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>
{#if loading}
<p class="text-(--color-muted)">Loading...</p>
{:else if tasks.length === 0}
<p class="text-(--color-muted)">No import tasks yet.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-(--color-muted) border-b border-(--color-border)">
<th class="pb-2">Title</th>
<th class="pb-2">Type</th>
<th class="pb-2">Status</th>
<th class="pb-2">Chapters</th>
<th class="pb-2">Started</th>
</tr>
</thead>
<tbody>
{#each tasks as task}
<tr class="border-b border-(--color-border)/50">
<td class="py-2">
<div class="font-medium">{task.title}</div>
<div class="text-xs text-(--color-muted)">{task.slug}</div>
</td>
<td class="py-2 uppercase text-xs">{task.file_type}</td>
<td class="py-2 {getStatusColor(task.status)}">{task.status}</td>
<td class="py-2 text-(--color-muted)">
{task.chapters_done}/{task.chapters_total}
</td>
<td class="py-2 text-(--color-muted)">{formatDate(task.started)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>

View File

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

View File

@@ -164,6 +164,7 @@
let coverPreview = $state<string | null>(null);
let coverSaving = $state(false);
let coverResult = $state<'saved' | 'error' | ''>('');
let coverErrorMsg = $state('');
let coverPromptOpen = $state(false);
function buildCoverPrompt(): string {
@@ -186,6 +187,7 @@
coverGenerating = true;
coverPreview = null;
coverResult = '';
coverErrorMsg = '';
const promptToUse = coverPrompt.trim() || buildCoverPrompt();
try {
let res: Response;
@@ -210,22 +212,25 @@
} else {
res = await fetch('/api/admin/image-gen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, type: 'cover', prompt: promptToUse })
});
}
if (res.ok) {
const d = await res.json();
coverPreview = d.image_b64 ? `data:${d.content_type ?? 'image/png'};base64,${d.image_b64}` : null;
} else {
coverResult = 'error';
}
} catch {
coverResult = 'error';
} finally {
coverGenerating = false;
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, type: 'cover', prompt: promptToUse })
});
}
if (res.ok) {
const d = await res.json();
coverPreview = d.image_b64 ? `data:${d.content_type ?? 'image/png'};base64,${d.image_b64}` : null;
} else {
const d = await res.json().catch(() => ({}));
coverErrorMsg = (d as any).error ?? '';
coverResult = 'error';
}
} catch (e: any) {
coverErrorMsg = e?.message ?? '';
coverResult = 'error';
} finally {
coverGenerating = false;
}
}
async function saveCover() {
const slug = data.book?.slug;
@@ -258,6 +263,7 @@
let chapterCoverGenerating = $state(false);
let chapterCoverPreview = $state<string | null>(null);
let chapterCoverResult = $state<'saved' | 'error' | ''>('');
let chapterCoverErrorMsg = $state('');
let chapterCoverSaving = $state(false);
let chapterCoverPrompt = $state('');
@@ -269,6 +275,7 @@
chapterCoverGenerating = true;
chapterCoverPreview = null;
chapterCoverResult = '';
chapterCoverErrorMsg = '';
const promptToUse = chapterCoverPrompt.trim() || `Chapter ${n} illustration for "${data.book?.title ?? slug}". Dramatic scene, vivid colors, detailed art, cinematic lighting.`;
try {
const res = await fetch('/api/admin/image-gen', {
@@ -280,9 +287,12 @@
const d = await res.json();
chapterCoverPreview = d.image_b64 ? `data:${d.content_type ?? 'image/png'};base64,${d.image_b64}` : null;
} else {
const d = await res.json().catch(() => ({}));
chapterCoverErrorMsg = (d as any).error ?? '';
chapterCoverResult = 'error';
}
} catch {
} catch (e: any) {
chapterCoverErrorMsg = e?.message ?? '';
chapterCoverResult = 'error';
} finally {
chapterCoverGenerating = false;
@@ -1101,7 +1111,7 @@
{m.book_detail_admin_generate()}{coverUseAsRef ? ' (img2img)' : ''}
</button>
{#if coverResult === 'error'}
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
<span class="text-xs text-(--color-danger)">{coverErrorMsg || m.common_error()}</span>
{:else if coverResult === 'saved'}
<span class="text-xs text-green-400">{m.book_detail_admin_saved()}</span>
{/if}
@@ -1159,7 +1169,7 @@
{m.book_detail_admin_generate()}
</button>
{#if chapterCoverResult === 'error'}
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
<span class="text-xs text-(--color-danger)">{chapterCoverErrorMsg || m.common_error()}</span>
{:else if chapterCoverResult === 'saved'}
<span class="text-xs text-green-400">{m.book_detail_admin_saved()}</span>
{/if}