feat: import review step + admin notifications
- Import page: add file upload with review step before committing - Backend: add analyze endpoint to preview chapters before import - Add notifications collection + API for admin alerts - Add bell icon in header with notification dropdown (admin only) - Runner creates notification on import completion - Notifications link to /admin/import for easy review
This commit is contained in:
@@ -203,6 +203,7 @@ func run() error {
|
||||
PocketTTS: pocketTTSClient,
|
||||
CFAI: cfaiClient,
|
||||
LibreTranslate: ltClient,
|
||||
Notifier: store,
|
||||
Log: log,
|
||||
}
|
||||
r := runner.New(rCfg, deps)
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user