Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e088bc056e | ||
|
|
a904ff4e21 | ||
|
|
04e63414a3 | ||
|
|
bae363893b | ||
|
|
b7306877f1 | ||
|
|
0723049e0c | ||
|
|
b206994459 |
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
141
backend/internal/backend/handlers_import.go
Normal file
141
backend/internal/backend/handlers_import.go
Normal 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})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
164
backend/internal/storage/import.go
Normal file
164
backend/internal/storage/import.go
Normal 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")
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
40
ui/src/lib/paraglide/messages/admin_ai_jobs_heading.js
Normal file
40
ui/src/lib/paraglide/messages/admin_ai_jobs_heading.js
Normal 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)
|
||||
});
|
||||
40
ui/src/lib/paraglide/messages/admin_ai_jobs_page_title.js
Normal file
40
ui/src/lib/paraglide/messages/admin_ai_jobs_page_title.js
Normal 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)
|
||||
});
|
||||
40
ui/src/lib/paraglide/messages/admin_ai_jobs_subheading.js
Normal file
40
ui/src/lib/paraglide/messages/admin_ai_jobs_subheading.js
Normal 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)
|
||||
});
|
||||
40
ui/src/lib/paraglide/messages/admin_text_gen_heading.js
Normal file
40
ui/src/lib/paraglide/messages/admin_text_gen_heading.js
Normal 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)
|
||||
});
|
||||
40
ui/src/lib/paraglide/messages/admin_text_gen_page_title.js
Normal file
40
ui/src/lib/paraglide/messages/admin_text_gen_page_title.js
Normal 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)
|
||||
});
|
||||
@@ -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)
|
||||
});
|
||||
40
ui/src/lib/paraglide/messages/admin_translation_heading.js
Normal file
40
ui/src/lib/paraglide/messages/admin_translation_heading.js
Normal 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)
|
||||
});
|
||||
40
ui/src/lib/paraglide/messages/admin_translation_no_jobs.js
Normal file
40
ui/src/lib/paraglide/messages/admin_translation_no_jobs.js
Normal 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)
|
||||
});
|
||||
@@ -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)
|
||||
});
|
||||
@@ -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)
|
||||
});
|
||||
@@ -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)
|
||||
});
|
||||
40
ui/src/lib/paraglide/messages/admin_translation_tab_jobs.js
Normal file
40
ui/src/lib/paraglide/messages/admin_translation_tab_jobs.js
Normal 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)
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
154
ui/src/routes/admin/import/+page.svelte
Normal file
154
ui/src/routes/admin/import/+page.svelte
Normal 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>
|
||||
38
ui/src/routes/api/admin/import/+server.ts
Normal file
38
ui/src/routes/api/admin/import/+server.ts
Normal 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);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user