diff --git a/backend/cmd/runner/main.go b/backend/cmd/runner/main.go index 869f5b0..5559acd 100644 --- a/backend/cmd/runner/main.go +++ b/backend/cmd/runner/main.go @@ -203,6 +203,7 @@ func run() error { PocketTTS: pocketTTSClient, CFAI: cfaiClient, LibreTranslate: ltClient, + Notifier: store, Log: log, } r := runner.New(rCfg, deps) diff --git a/backend/internal/backend/handlers_import.go b/backend/internal/backend/handlers_import.go index 27fe09c..3fef60c 100644 --- a/backend/internal/backend/handlers_import.go +++ b/backend/internal/backend/handlers_import.go @@ -20,8 +20,14 @@ type importRequest struct { } type importResponse struct { - TaskID string `json:"task_id"` - Slug string `json:"slug"` + TaskID string `json:"task_id"` + Slug string `json:"slug"` + Preview *importPreview `json:"preview,omitempty"` +} + +type importPreview struct { + Chapters int `json:"chapters"` + FirstLines []string `json:"first_lines"` } func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) { @@ -42,6 +48,7 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) { req.Title = r.FormValue("title") req.FileName = r.FormValue("file_name") req.FileType = r.FormValue("file_type") + analyzeOnly := r.FormValue("analyze") == "true" file, header, err := r.FormFile("file") if err != nil { @@ -63,7 +70,16 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) { return } - // Upload to MinIO directly via the store + // Analyze only - just count chapters + if analyzeOnly { + preview := analyzeImportFile(data, req.FileType) + writeJSON(w, 0, importResponse{ + Preview: preview, + }) + return + } + + // Upload to MinIO for actual import objectKey = fmt.Sprintf("imports/%d_%s", time.Now().Unix(), header.Filename) store, ok := s.deps.Producer.(*storage.Store) if !ok { @@ -111,6 +127,27 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) { }) } +// analyzeImportFile does a quick scan of the file to count chapters. +// This is a placeholder - real implementation would parse PDF/EPUB properly. +func analyzeImportFile(data []byte, fileType string) *importPreview { + // TODO: Implement actual PDF/EPUB parsing to count chapters + // For now, estimate based on file size + preview := &importPreview{ + Chapters: estimateChapters(data, fileType), + FirstLines: []string{}, + } + return preview +} + +func estimateChapters(data []byte, fileType string) int { + // Rough estimate: ~100KB per chapter for PDF, ~50KB for EPUB + size := len(data) + if fileType == "pdf" { + return size / 100000 + } + return size / 50000 +} + func (s *Server) handleAdminImportStatus(w http.ResponseWriter, r *http.Request) { taskID := r.PathValue("id") if taskID == "" { diff --git a/backend/internal/backend/server.go b/backend/internal/backend/server.go index ce787bc..430b7a2 100644 --- a/backend/internal/backend/server.go +++ b/backend/internal/backend/server.go @@ -249,6 +249,10 @@ func (s *Server) ListenAndServe(ctx context.Context) error { mux.HandleFunc("GET /api/admin/import", s.handleAdminImportList) mux.HandleFunc("GET /api/admin/import/{id}", s.handleAdminImportStatus) + // Notifications + mux.HandleFunc("GET /api/notifications", s.handleListNotifications) + mux.HandleFunc("PATCH /api/notifications/{id}", s.handleMarkNotificationRead) + // Voices list mux.HandleFunc("GET /api/voices", s.handleVoices) diff --git a/backend/internal/runner/runner.go b/backend/internal/runner/runner.go index 07837e3..3592d4b 100644 --- a/backend/internal/runner/runner.go +++ b/backend/internal/runner/runner.go @@ -39,6 +39,11 @@ import ( "github.com/prometheus/client_golang/prometheus" ) +// Notifier creates notifications for users. +type Notifier interface { + CreateNotification(ctx context.Context, userID, title, message, link string) error +} + // Config tunes the runner behaviour. type Config struct { // WorkerID uniquely identifies this runner instance in PocketBase records. @@ -105,6 +110,8 @@ type Dependencies struct { CoverStore bookstore.CoverStore // BookImport handles PDF/EPUB file parsing and chapter extraction. BookImport bookstore.BookImporter + // Notifier creates notifications for users. + Notifier Notifier // SearchIndex indexes books in Meilisearch after scraping. // If nil a no-op is used. SearchIndex meili.Client @@ -721,6 +728,13 @@ func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, obje if err := r.deps.Consumer.FinishImportTask(ctx, task.ID, result); err != nil { log.Error("runner: FinishImportTask failed", "err", err) } + + // Create notification for admin + if r.deps.Notifier != nil { + msg := fmt.Sprintf("Import completed: %d chapters from %s", len(chapters), task.Title) + _ = r.deps.Notifier.CreateNotification(ctx, "admin", "Import Complete", msg, "/admin/import") + } + log.Info("runner: import task finished", "chapters", len(chapters)) } diff --git a/backend/internal/storage/store.go b/backend/internal/storage/store.go index 99b65f0..dd6944d 100644 --- a/backend/internal/storage/store.go +++ b/backend/internal/storage/store.go @@ -667,6 +667,46 @@ func (s *Store) CreateImportTask(ctx context.Context, slug, title, fileType, obj return rec.ID, nil } +// CreateNotification creates a notification record in PocketBase. +func (s *Store) CreateNotification(ctx context.Context, userID, title, message, link string) error { + payload := map[string]any{ + "user_id": userID, + "title": title, + "message": message, + "link": link, + "read": false, + "created": time.Now().UTC().Format(time.RFC3339), + } + return s.pb.post(ctx, "/api/collections/notifications/records", payload, nil) +} + +// ListNotifications returns notifications for a user. +func (s *Store) ListNotifications(ctx context.Context, userID string, limit int) ([]map[string]any, error) { + filter := fmt.Sprintf("user_id='%s'", userID) + items, err := s.pb.listAll(ctx, "notifications", filter, "-created") + if err != nil { + return nil, err + } + // Parse each json.RawMessage into a map + results := make([]map[string]any, 0, len(items)) + for _, raw := range items { + var m map[string]any + if json.Unmarshal(raw, &m) == nil { + results = append(results, m) + } + } + if limit > 0 && len(results) > limit { + results = results[:limit] + } + return results, nil +} + +// MarkNotificationRead marks a notification as read. +func (s *Store) MarkNotificationRead(ctx context.Context, id string) error { + return s.pb.patch(ctx, fmt.Sprintf("/api/collections/notifications/records/%s", id), + map[string]any{"read": true}) +} + func (s *Store) CancelTask(ctx context.Context, id string) error { // Try scraping_tasks first, then audio_jobs, then translation_jobs. if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte index adcbeb0..c2cd003 100644 --- a/ui/src/routes/+layout.svelte +++ b/ui/src/routes/+layout.svelte @@ -23,6 +23,28 @@ // Universal search let searchOpen = $state(false); + // Notifications + let notificationsOpen = $state(false); + let notifications = $state<{id: string; title: string; message: string; link: string; read: boolean}[]>([]); + async function loadNotifications() { + if (!data.user) return; + try { + const res = await fetch('/api/notifications?user_id=' + data.user.id); + if (res.ok) { + const d = await res.json(); + notifications = d.notifications || []; + } + } catch (e) { console.error('load notifications:', e); } + } + async function markRead(id: string) { + try { + await fetch('/api/notifications/' + id, { method: 'PATCH' }); + notifications = notifications.map(n => n.id === id ? {...n, read: true} : n); + } catch (e) { console.error('mark read:', e); } + } + $effect(() => { if (data.user) loadNotifications(); }); + const unreadCount = $derived(notifications.filter(n => !n.read).length); + // Close search on navigation $effect(() => { void page.url.pathname; @@ -529,7 +551,7 @@ {#if !/\/books\/[^/]+\/chapters\//.test(page.url.pathname)} {/if} + + + {#if data.user?.role === 'admin'} +