diff --git a/backend/internal/asynqqueue/producer.go b/backend/internal/asynqqueue/producer.go index b4edc47..b9478c7 100644 --- a/backend/internal/asynqqueue/producer.go +++ b/backend/internal/asynqqueue/producer.go @@ -7,6 +7,7 @@ import ( "log/slog" "github.com/hibiken/asynq" + "github.com/libnovel/backend/internal/domain" "github.com/libnovel/backend/internal/taskqueue" ) @@ -88,18 +89,18 @@ func (p *Producer) CreateTranslationTask(ctx context.Context, slug string, chapt } // CreateImportTask creates a PocketBase record then enqueues an Asynq job for PDF/EPUB import. -func (p *Producer) CreateImportTask(ctx context.Context, slug, title, fileType, objectKey, initiatorUserID string) (string, error) { - id, err := p.pb.CreateImportTask(ctx, slug, title, fileType, objectKey, initiatorUserID) +func (p *Producer) CreateImportTask(ctx context.Context, task domain.ImportTask) (string, error) { + id, err := p.pb.CreateImportTask(ctx, task) if err != nil { return "", err } payload := ImportPayload{ PBTaskID: id, - Slug: slug, - Title: title, - FileType: fileType, - ObjectKey: objectKey, + Slug: task.Slug, + Title: task.Title, + FileType: task.FileType, + ObjectKey: task.ObjectKey, } if err := p.enqueue(ctx, TypeImportBook, payload); err != nil { // Non-fatal: PB record exists; runner will pick it up on next poll. diff --git a/backend/internal/backend/handlers_import.go b/backend/internal/backend/handlers_import.go index 624168d..71255e1 100644 --- a/backend/internal/backend/handlers_import.go +++ b/backend/internal/backend/handlers_import.go @@ -9,14 +9,20 @@ import ( "strings" "time" + "github.com/libnovel/backend/internal/domain" "github.com/libnovel/backend/internal/storage" ) type importRequest struct { - Title string `json:"title"` - FileName string `json:"file_name"` - FileType string `json:"file_type"` // "pdf" or "epub" - ObjectKey string `json:"object_key"` // MinIO path to uploaded file + Title string `json:"title"` + Author string `json:"author"` + CoverURL string `json:"cover_url"` + Genres []string `json:"genres"` + Summary string `json:"summary"` + BookStatus string `json:"book_status"` // "ongoing" | "completed" | "hiatus" + FileName string `json:"file_name"` + FileType string `json:"file_type"` // "pdf" or "epub" + ObjectKey string `json:"object_key"` // MinIO path to uploaded file } type importResponse struct { @@ -46,6 +52,17 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) { return } req.Title = r.FormValue("title") + req.Author = r.FormValue("author") + req.CoverURL = r.FormValue("cover_url") + req.Summary = r.FormValue("summary") + req.BookStatus = r.FormValue("book_status") + if g := r.FormValue("genres"); g != "" { + for _, s := range strings.Split(g, ",") { + if s = strings.TrimSpace(s); s != "" { + req.Genres = append(req.Genres, s) + } + } + } req.FileName = r.FormValue("file_name") req.FileType = r.FormValue("file_type") analyzeOnly := r.FormValue("analyze") == "true" @@ -115,7 +132,18 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) { return -1 }, slug) - taskID, err := s.deps.Producer.CreateImportTask(r.Context(), slug, req.Title, req.FileType, objectKey, "") + taskID, err := s.deps.Producer.CreateImportTask(r.Context(), domain.ImportTask{ + Slug: slug, + Title: req.Title, + Author: req.Author, + CoverURL: req.CoverURL, + Genres: req.Genres, + Summary: req.Summary, + BookStatus: req.BookStatus, + FileType: req.FileType, + ObjectKey: objectKey, + InitiatorUserID: "", + }) if err != nil { jsonError(w, http.StatusInternalServerError, "create import task: "+err.Error()) return diff --git a/backend/internal/domain/domain.go b/backend/internal/domain/domain.go index 8b8b1d6..c7b509e 100644 --- a/backend/internal/domain/domain.go +++ b/backend/internal/domain/domain.go @@ -177,6 +177,12 @@ type ImportTask struct { Title string `json:"title"` FileName string `json:"file_name"` FileType string `json:"file_type"` // "pdf" or "epub" + ObjectKey string `json:"object_key,omitempty"` // MinIO path to uploaded file + Author string `json:"author,omitempty"` + CoverURL string `json:"cover_url,omitempty"` + Genres []string `json:"genres,omitempty"` + Summary string `json:"summary,omitempty"` + BookStatus string `json:"book_status,omitempty"` // "ongoing" | "completed" | "hiatus" WorkerID string `json:"worker_id,omitempty"` InitiatorUserID string `json:"initiator_user_id,omitempty"` // PocketBase user ID who submitted the import Status TaskStatus `json:"status"` diff --git a/backend/internal/runner/runner.go b/backend/internal/runner/runner.go index bf07cfb..16da7c6 100644 --- a/backend/internal/runner/runner.go +++ b/backend/internal/runner/runner.go @@ -432,9 +432,7 @@ importLoop: defer wg.Done() defer func() { <-importSem }() defer r.tasksRunning.Add(-1) - // Import tasks need object key - we'll need to fetch it from the task record - // For now, assume it's stored in a field or we need to add it - r.runImportTask(ctx, t, "") + r.runImportTask(ctx, t, t.ObjectKey) }(task) } } @@ -753,6 +751,31 @@ func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, obje return } + // Write book metadata so the book appears in PocketBase catalogue. + if r.deps.BookWriter != nil { + meta := domain.BookMeta{ + Slug: task.Slug, + Title: task.Title, + Author: task.Author, + Cover: task.CoverURL, + Status: task.BookStatus, + Genres: task.Genres, + Summary: task.Summary, + TotalChapters: len(chapters), + } + if meta.Status == "" { + meta.Status = "completed" + } + if err := r.deps.BookWriter.WriteMetadata(ctx, meta); err != nil { + log.Warn("runner: import task WriteMetadata failed (non-fatal)", "err", err) + } else { + // Index in Meilisearch so the book is searchable. + if err := r.deps.SearchIndex.UpsertBook(ctx, meta); err != nil { + log.Warn("runner: import task meilisearch upsert failed (non-fatal)", "err", err) + } + } + } + r.tasksCompleted.Add(1) span.SetStatus(codes.Ok, "") result := domain.ImportResult{ diff --git a/backend/internal/storage/store.go b/backend/internal/storage/store.go index b95caae..8ad02d2 100644 --- a/backend/internal/storage/store.go +++ b/backend/internal/storage/store.go @@ -647,17 +647,25 @@ func (s *Store) CreateTranslationTask(ctx context.Context, slug string, chapter return rec.ID, nil } -func (s *Store) CreateImportTask(ctx context.Context, slug, title, fileType, objectKey, initiatorUserID string) (string, error) { +func (s *Store) CreateImportTask(ctx context.Context, task domain.ImportTask) (string, error) { payload := map[string]any{ - "slug": slug, - "title": title, - "file_name": slug + "." + fileType, - "file_type": fileType, + "slug": task.Slug, + "title": task.Title, + "file_name": task.Slug + "." + task.FileType, + "file_type": task.FileType, + "object_key": task.ObjectKey, + "author": task.Author, + "cover_url": task.CoverURL, + "summary": task.Summary, + "book_status": task.BookStatus, "status": string(domain.TaskStatusPending), "chapters_done": 0, "chapters_total": 0, "started": time.Now().UTC().Format(time.RFC3339), - "initiator_user_id": initiatorUserID, + "initiator_user_id": task.InitiatorUserID, + } + if len(task.Genres) > 0 { + payload["genres"] = strings.Join(task.Genres, ",") } var rec struct { ID string `json:"id"` @@ -1176,6 +1184,12 @@ func parseImportTask(raw json.RawMessage) (domain.ImportTask, error) { Title string `json:"title"` FileName string `json:"file_name"` FileType string `json:"file_type"` + ObjectKey string `json:"object_key"` + Author string `json:"author"` + CoverURL string `json:"cover_url"` + Genres string `json:"genres"` // stored as comma-separated + Summary string `json:"summary"` + BookStatus string `json:"book_status"` WorkerID string `json:"worker_id"` InitiatorUserID string `json:"initiator_user_id"` Status string `json:"status"` @@ -1190,12 +1204,26 @@ func parseImportTask(raw json.RawMessage) (domain.ImportTask, error) { } started, _ := time.Parse(time.RFC3339, rec.Started) finished, _ := time.Parse(time.RFC3339, rec.Finished) + var genres []string + if rec.Genres != "" { + for _, g := range strings.Split(rec.Genres, ",") { + if g = strings.TrimSpace(g); g != "" { + genres = append(genres, g) + } + } + } return domain.ImportTask{ ID: rec.ID, Slug: rec.Slug, Title: rec.Title, FileName: rec.FileName, FileType: rec.FileType, + ObjectKey: rec.ObjectKey, + Author: rec.Author, + CoverURL: rec.CoverURL, + Genres: genres, + Summary: rec.Summary, + BookStatus: rec.BookStatus, WorkerID: rec.WorkerID, InitiatorUserID: rec.InitiatorUserID, Status: domain.TaskStatus(rec.Status), diff --git a/backend/internal/taskqueue/taskqueue.go b/backend/internal/taskqueue/taskqueue.go index 78fa2d8..1c9fd9b 100644 --- a/backend/internal/taskqueue/taskqueue.go +++ b/backend/internal/taskqueue/taskqueue.go @@ -35,8 +35,8 @@ type Producer interface { // CreateImportTask inserts a new import task with status=pending and // returns the assigned PocketBase record ID. - // initiatorUserID is the PocketBase user ID who submitted the import (may be empty). - CreateImportTask(ctx context.Context, slug, title, fileType, objectKey, initiatorUserID string) (string, error) + // The task struct must have at minimum Slug, Title, FileType, and ObjectKey set. + CreateImportTask(ctx context.Context, task domain.ImportTask) (string, error) // CancelTask transitions a pending task to status=cancelled. // Returns ErrNotFound if the task does not exist. diff --git a/backend/internal/taskqueue/taskqueue_test.go b/backend/internal/taskqueue/taskqueue_test.go index 4ccab18..9f8969a 100644 --- a/backend/internal/taskqueue/taskqueue_test.go +++ b/backend/internal/taskqueue/taskqueue_test.go @@ -26,7 +26,7 @@ func (s *stubStore) CreateAudioTask(_ context.Context, _ string, _ int, _ string func (s *stubStore) CreateTranslationTask(_ context.Context, _ string, _ int, _ string) (string, error) { return "translation-1", nil } -func (s *stubStore) CreateImportTask(_ context.Context, _, _, _, _, _ string) (string, error) { +func (s *stubStore) CreateImportTask(_ context.Context, _ domain.ImportTask) (string, error) { return "import-1", nil } func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil } diff --git a/ui/messages/en.json b/ui/messages/en.json index dcaa371..efb66e3 100644 --- a/ui/messages/en.json +++ b/ui/messages/en.json @@ -408,6 +408,7 @@ "admin_nav_text_gen": "Text Gen", "admin_nav_catalogue_tools": "Catalogue Tools", "admin_nav_ai_jobs": "AI Jobs", + "admin_nav_notifications": "Notifications", "admin_nav_feedback": "Feedback", "admin_nav_errors": "Errors", "admin_nav_analytics": "Analytics", diff --git a/ui/messages/fr.json b/ui/messages/fr.json index a5074aa..ffbd82a 100644 --- a/ui/messages/fr.json +++ b/ui/messages/fr.json @@ -378,6 +378,7 @@ "admin_nav_text_gen": "Text Gen", "admin_nav_catalogue_tools": "Catalogue Tools", "admin_nav_ai_jobs": "Tâches IA", + "admin_nav_notifications": "Notifications", "admin_nav_errors": "Erreurs", "admin_nav_analytics": "Analytique", "admin_nav_logs": "Journaux", diff --git a/ui/messages/id.json b/ui/messages/id.json index c3047e7..05aa86b 100644 --- a/ui/messages/id.json +++ b/ui/messages/id.json @@ -378,6 +378,7 @@ "admin_nav_text_gen": "Text Gen", "admin_nav_catalogue_tools": "Catalogue Tools", "admin_nav_ai_jobs": "Tugas AI", + "admin_nav_notifications": "Notifikasi", "admin_nav_errors": "Kesalahan", "admin_nav_analytics": "Analitik", "admin_nav_logs": "Log", diff --git a/ui/messages/pt.json b/ui/messages/pt.json index 268ef48..8223d3b 100644 --- a/ui/messages/pt.json +++ b/ui/messages/pt.json @@ -378,6 +378,7 @@ "admin_nav_text_gen": "Text Gen", "admin_nav_catalogue_tools": "Catalogue Tools", "admin_nav_ai_jobs": "Tarefas de IA", + "admin_nav_notifications": "Notificações", "admin_nav_errors": "Erros", "admin_nav_analytics": "Análise", "admin_nav_logs": "Logs", diff --git a/ui/messages/ru.json b/ui/messages/ru.json index 7e8c7cb..d7581e5 100644 --- a/ui/messages/ru.json +++ b/ui/messages/ru.json @@ -378,6 +378,7 @@ "admin_nav_text_gen": "Text Gen", "admin_nav_catalogue_tools": "Catalogue Tools", "admin_nav_ai_jobs": "Задачи ИИ", + "admin_nav_notifications": "Уведомления", "admin_nav_errors": "Ошибки", "admin_nav_analytics": "Аналитика", "admin_nav_logs": "Логи", diff --git a/ui/src/lib/paraglide/messages/_index.js b/ui/src/lib/paraglide/messages/_index.js index f936cdb..96daa0f 100644 --- a/ui/src/lib/paraglide/messages/_index.js +++ b/ui/src/lib/paraglide/messages/_index.js @@ -379,6 +379,7 @@ export * from './admin_nav_image_gen.js' export * from './admin_nav_text_gen.js' export * from './admin_nav_catalogue_tools.js' export * from './admin_nav_ai_jobs.js' +export * from './admin_nav_notifications.js' export * from './admin_nav_feedback.js' export * from './admin_nav_errors.js' export * from './admin_nav_analytics.js' diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index 15deb9f..d6f51cc 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -1383,6 +1383,20 @@ export async function revokeUserSession(recordId: string, userId: string): Promi return del.ok || del.status === 204; } +/** + * Delete a session by its auth session ID (the value stored in the cookie). + * Used on logout so the row doesn't linger as a phantom active session. + */ +export async function deleteSessionByAuthId(authSessionId: string): Promise { + const row = await listOne('user_sessions', `session_id="${authSessionId}"`); + if (!row) return; + const token = await getToken(); + await fetch(`${PB_URL}/api/collections/user_sessions/records/${row.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` } + }).catch(() => {}); +} + /** * Revoke all sessions for a user (used on password change etc). */ diff --git a/ui/src/routes/admin/+layout.svelte b/ui/src/routes/admin/+layout.svelte index a8495ef..e651e4e 100644 --- a/ui/src/routes/admin/+layout.svelte +++ b/ui/src/routes/admin/+layout.svelte @@ -38,6 +38,11 @@ label: () => m.admin_nav_ai_jobs(), icon: `` }, + { + href: '/admin/notifications', + label: () => m.admin_nav_notifications(), + icon: `` + }, { href: '/admin/catalogue-tools', label: () => m.admin_nav_catalogue_tools(), diff --git a/ui/src/routes/admin/import/+page.svelte b/ui/src/routes/admin/import/+page.svelte index da072cf..5d7e3c6 100644 --- a/ui/src/routes/admin/import/+page.svelte +++ b/ui/src/routes/admin/import/+page.svelte @@ -1,5 +1,6 @@ -
-

Import PDF/EPUB

+
+

Import PDF/EPUB

{#if pendingImport} - -
-

Review Import

-
-
- Title: - {pendingImport.title} + +
+

Review Import

+
+
+
Title
+
{pendingImport.title}
-
- File: - {pendingImport.file.name} -
-
- Size: - {(pendingImport.file.size / 1024 / 1024).toFixed(2)} MB -
- {#if pendingImport.preview.chapters > 0} -
- Detected chapters: - {pendingImport.preview.chapters} + {#if pendingImport.author} +
+
Author
+
{pendingImport.author}
{/if} -
-
+ {#if pendingImport.genres} +
+
Genres
+
{pendingImport.genres}
+
+ {/if} +
+
Status
+
{pendingImport.bookStatus}
+
+
+
File
+
{pendingImport.file.name}
+
+
+
Size
+
{(pendingImport.file.size / 1024 / 1024).toFixed(2)} MB
+
+ {#if pendingImport.preview.chapters > 0} +
+
Detected chapters
+
{pendingImport.preview.chapters}
+
+ {/if} +
+ {#if pendingImport.preview.firstLines?.length} +
+

First lines preview:

+ {#each pendingImport.preview.firstLines as line} +

{line}

+ {/each} +
+ {/if} +
+ {:else} - -
{ e.preventDefault(); analyzeFile(); }} class="mb-8 p-4 bg-(--color-surface-2) rounded-lg"> -
- + + { e.preventDefault(); analyzeFile(); }} + class="p-6 bg-(--color-surface-2) rounded-lg space-y-4" + > + +
+
-
- + + +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ {#if error} -

{error}

+

{error}

{/if} + -

- Select a file to preview chapter count before importing. -

+

Detects chapter structure before committing.

{/if} - -

Import Tasks

- - {#if loading} -

Loading...

- {:else if tasks.length === 0} -

No import tasks yet.

- {:else} -
- - - - - - - - - - - - {#each tasks as task} - - - - - - - - {/each} - -
TitleTypeStatusChaptersStarted
-
{task.title}
-
{task.slug}
-
{task.file_type}{task.status} - {task.chapters_done}/{task.chapters_total} - {formatDate(task.started)}
+ + {#if showAiPanel && aiSlug} +
+
+

AI Tasks for {aiTitle || aiSlug}

+ +
+

Run AI tasks on the imported book to enrich it:

+
{/if} -
\ No newline at end of file + + +
+

Import Tasks

+ + {#if loading} +

Loading…

+ {:else if tasks.length === 0} +

No import tasks yet.

+ {:else} +
+ + + + + + + + + + + + + {#each tasks as task} + + + + + + + + + {/each} + +
TitleTypeStatusChaptersStartedAI
+
{task.title}
+
{task.slug}
+ {#if task.error_message} +
{task.error_message}
+ {/if} +
{task.file_type}{task.status} + {task.chapters_done}/{task.chapters_total} + {formatDate(task.started)} + {#if task.status === 'done'} + + {/if} +
+
+ {/if} +
+
diff --git a/ui/src/routes/api/auth/logout/+server.ts b/ui/src/routes/api/auth/logout/+server.ts index 9321e34..b83cefc 100644 --- a/ui/src/routes/api/auth/logout/+server.ts +++ b/ui/src/routes/api/auth/logout/+server.ts @@ -1,15 +1,24 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; +import { parseAuthToken } from '../../../../hooks.server.js'; +import { deleteSessionByAuthId } from '$lib/server/pocketbase'; const AUTH_COOKIE = 'libnovel_auth'; /** * POST /api/auth/logout - * Clears the auth cookie and returns { ok: true }. - * Does not revoke the session record from PocketBase — - * for full revocation use DELETE /api/sessions/[id] first. + * Deletes the session row from PocketBase AND clears the auth cookie, so the + * session doesn't linger as a phantom "active session" after sign-out. */ export const POST: RequestHandler = async ({ cookies }) => { + const token = cookies.get(AUTH_COOKIE); + if (token) { + const user = parseAuthToken(token); + if (user?.authSessionId) { + // Best-effort — non-fatal if PocketBase is unreachable. + deleteSessionByAuthId(user.authSessionId).catch(() => {}); + } + } cookies.delete(AUTH_COOKIE, { path: '/' }); return json({ ok: true }); };