Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab92bf84bb |
@@ -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),
|
||||
|
||||
@@ -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)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { searchOpen = true; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = false; }}
|
||||
onclick={() => { searchOpen = true; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = false; notificationsOpen = false; }}
|
||||
title="Search (/ or ⌘K)"
|
||||
aria-label="Search books"
|
||||
class="flex items-center justify-center w-8 h-8 rounded transition-colors text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)"
|
||||
@@ -539,6 +561,43 @@
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Notifications bell -->
|
||||
{#if data.user?.role === 'admin'}
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { notificationsOpen = !notificationsOpen; searchOpen = false; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; }}
|
||||
title="Notifications"
|
||||
class="flex items-center justify-center w-8 h-8 rounded transition-colors {notificationsOpen ? 'bg-(--color-surface-2)' : 'hover:bg-(--color-surface-2)'} relative"
|
||||
>
|
||||
<svg class="w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
|
||||
</svg>
|
||||
{#if unreadCount > 0}
|
||||
<span class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if notificationsOpen}
|
||||
<div class="absolute right-0 top-full mt-1 w-80 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl z-50 max-h-96 overflow-y-auto">
|
||||
{#if notifications.length === 0}
|
||||
<div class="p-4 text-center text-(--color-muted) text-sm">No notifications</div>
|
||||
{:else}
|
||||
{#each notifications as n}
|
||||
<a
|
||||
href={n.link || '/admin/import'}
|
||||
onclick={() => { markRead(n.id); notificationsOpen = false; }}
|
||||
class="block p-3 border-b border-(--color-border)/50 hover:bg-(--color-surface-3) {n.read ? 'opacity-60' : ''}"
|
||||
>
|
||||
<div class="text-sm font-medium">{n.title}</div>
|
||||
<div class="text-xs text-(--color-muted) mt-0.5">{n.message}</div>
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Theme dropdown (desktop) -->
|
||||
<div class="hidden sm:block relative">
|
||||
<button
|
||||
|
||||
@@ -15,11 +15,20 @@
|
||||
finished: string;
|
||||
}
|
||||
|
||||
interface PendingImport {
|
||||
file: File;
|
||||
title: string;
|
||||
preview: { chapters: number; firstLines: string[] };
|
||||
}
|
||||
|
||||
let tasks = $state<ImportTask[]>([]);
|
||||
let loading = $state(true);
|
||||
let uploading = $state(false);
|
||||
let title = $state('');
|
||||
let error = $state('');
|
||||
let selectedFile = $state<File | null>(null);
|
||||
let pendingImport = $state<PendingImport | null>(null);
|
||||
let analyzing = $state(false);
|
||||
|
||||
async function loadTasks() {
|
||||
loading = true;
|
||||
@@ -36,35 +45,82 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
async function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (!input.files?.length) return;
|
||||
const file = input.files[0];
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
||||
if (ext !== 'pdf' && ext !== 'epub') {
|
||||
error = 'Please select a PDF or EPUB file';
|
||||
return;
|
||||
}
|
||||
selectedFile = file;
|
||||
title = file.name.replace(/\.(pdf|epub)$/i, '').replace(/[-_]/g, ' ');
|
||||
}
|
||||
|
||||
uploading = true;
|
||||
async function analyzeFile() {
|
||||
if (!selectedFile || !title.trim()) return;
|
||||
analyzing = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
formData.append('title', title);
|
||||
formData.append('analyze', 'true');
|
||||
const res = await fetch('/api/admin/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title })
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
pendingImport = {
|
||||
file: selectedFile,
|
||||
title: title,
|
||||
preview: data.preview || { chapters: 0, firstLines: [] }
|
||||
};
|
||||
} else {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
error = d.error || 'Failed to analyze file';
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Failed to analyze file';
|
||||
} finally {
|
||||
analyzing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function startImport() {
|
||||
if (!pendingImport) return;
|
||||
uploading = true;
|
||||
error = '';
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', pendingImport.file);
|
||||
formData.append('title', pendingImport.title);
|
||||
const res = await fetch('/api/admin/import', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
if (res.ok) {
|
||||
pendingImport = null;
|
||||
selectedFile = null;
|
||||
title = '';
|
||||
await loadTasks();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
error = data.error || 'Upload failed';
|
||||
const d = await res.json().catch(() => ({}));
|
||||
error = d.error || 'Import failed';
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Upload failed';
|
||||
error = 'Import failed';
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelReview() {
|
||||
pendingImport = null;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleString();
|
||||
@@ -88,30 +144,82 @@
|
||||
<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)"
|
||||
/>
|
||||
{#if pendingImport}
|
||||
<!-- Review Step -->
|
||||
<div class="mb-8 p-6 bg-(--color-surface-2) rounded-lg border border-(--color-brand)/30">
|
||||
<h2 class="text-lg font-semibold mb-4">Review Import</h2>
|
||||
<div class="space-y-3 mb-6">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-(--color-muted)">Title:</span>
|
||||
<span class="font-medium">{pendingImport.title}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-(--color-muted)">File:</span>
|
||||
<span>{pendingImport.file.name}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-(--color-muted)">Size:</span>
|
||||
<span>{(pendingImport.file.size / 1024 / 1024).toFixed(2)} MB</span>
|
||||
</div>
|
||||
{#if pendingImport.preview.chapters > 0}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-(--color-muted)">Detected chapters:</span>
|
||||
<span class="text-green-400">{pendingImport.preview.chapters}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={startImport}
|
||||
disabled={uploading}
|
||||
class="px-4 py-2 bg-green-600 text-white rounded font-medium disabled:opacity-50"
|
||||
>
|
||||
{uploading ? 'Starting...' : 'Start Import'}
|
||||
</button>
|
||||
<button
|
||||
onclick={cancelReview}
|
||||
class="px-4 py-2 border border-(--color-border) rounded font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Upload Form -->
|
||||
<form onsubmit={(e) => { e.preventDefault(); analyzeFile(); }} class="mb-8 p-4 bg-(--color-surface-2) rounded-lg">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2">Select File (PDF or EPUB)</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,.epub"
|
||||
onchange={handleFileSelect}
|
||||
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text)"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2">Book Title</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={title}
|
||||
placeholder="Enter book title"
|
||||
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text)"
|
||||
/>
|
||||
</div>
|
||||
{#if error}
|
||||
<p class="mb-4 text-sm text-red-400">{error}</p>
|
||||
{/if}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={uploading || !title.trim()}
|
||||
disabled={analyzing || !selectedFile || !title.trim()}
|
||||
class="px-4 py-2 bg-(--color-brand) text-(--color-surface) rounded font-medium disabled:opacity-50"
|
||||
>
|
||||
{uploading ? 'Creating...' : 'Import'}
|
||||
{analyzing ? 'Analyzing...' : 'Review & 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>
|
||||
<p class="mt-2 text-xs text-(--color-muted)">
|
||||
Select a file to preview chapter count before importing.
|
||||
</p>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<!-- Task List -->
|
||||
<h2 class="text-lg font-semibold mb-4">Import Tasks</h2>
|
||||
|
||||
Reference in New Issue
Block a user