feat: PDF single-chapter import, EPUB numbering fix, admin chapter split tool
- parsePDF: return all text as single 'Full Text' chapter (admin splits manually) - parseEPUB: fix chapter numbering to use sequential counter not spine index - Remove dead code: chaptersFromBookmarks, cleanChapterText, extractChaptersFromText, chapterHeadingRE; drop pdfcpu alias and regexp imports - Backend: POST /api/admin/books/:slug/split-chapters endpoint — splits text on '---' dividers, optional '## Title' headers, writes chapters via WriteChapter - UI: admin panel now shows for all admin users regardless of source_url; chapter split tool shown when book has single 'Full Text' chapter, pre-fills from MinIO content
This commit is contained in:
@@ -100,6 +100,58 @@
|
||||
const genres = $derived(parseGenres(data.book?.genres ?? []));
|
||||
const chapterList = $derived(data.chapters ?? []);
|
||||
|
||||
// ── Admin: split chapters (imported PDF/EPUB books) ──────────────────────
|
||||
const isFullTextBook = $derived(
|
||||
chapterList.length === 1 && chapterList[0].title === 'Full Text'
|
||||
);
|
||||
let splitText = $state('');
|
||||
let splitSaving = $state(false);
|
||||
let splitResult = $state<'saved' | 'error' | ''>('');
|
||||
let splitError = $state('');
|
||||
let splitOpen = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
// Pre-fill the textarea with chapter 1 content when the panel is opened.
|
||||
if (splitOpen && !splitText && data.book?.slug && isFullTextBook) {
|
||||
fetch(`/api/chapter-markdown/${encodeURIComponent(data.book.slug)}/1`)
|
||||
.then((r) => r.ok ? r.text() : '')
|
||||
.then((t) => {
|
||||
// Strip leading "# Full Text\n\n" header if present.
|
||||
splitText = t.replace(/^# Full Text\n\n/, '').trim();
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
async function splitChapters() {
|
||||
const slug = data.book?.slug;
|
||||
if (splitSaving || !slug) return;
|
||||
splitSaving = true;
|
||||
splitResult = '';
|
||||
splitError = '';
|
||||
try {
|
||||
const res = await fetch(`/api/admin/books/${encodeURIComponent(slug)}/split-chapters`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: splitText })
|
||||
});
|
||||
if (res.ok) {
|
||||
splitResult = 'saved';
|
||||
splitOpen = false;
|
||||
await invalidateAll();
|
||||
} else {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
splitError = (d as any).error ?? 'Unknown error';
|
||||
splitResult = 'error';
|
||||
}
|
||||
} catch (e: any) {
|
||||
splitError = e?.message ?? '';
|
||||
splitResult = 'error';
|
||||
} finally {
|
||||
splitSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin: rescrape ───────────────────────────────────────────────────────
|
||||
let scraping = $state(false);
|
||||
let scrapeResult = $state<'queued' | 'busy' | 'error' | ''>('');
|
||||
@@ -979,7 +1031,7 @@
|
||||
</a>
|
||||
|
||||
<!-- Admin panel (collapsed by default, admin only) -->
|
||||
{#if data.isAdmin && book.source_url}
|
||||
{#if data.isAdmin}
|
||||
<div>
|
||||
<button
|
||||
onclick={() => (adminOpen = !adminOpen)}
|
||||
@@ -997,6 +1049,62 @@
|
||||
|
||||
{#if adminOpen}
|
||||
<div class="px-4 py-3 border-t border-(--color-border) flex flex-col gap-5">
|
||||
|
||||
<!-- Chapter split tool (only for imported books with single "Full Text" chapter) -->
|
||||
{#if isFullTextBook}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">Split Chapters</p>
|
||||
<button
|
||||
onclick={() => { splitOpen = !splitOpen; splitResult = ''; splitError = ''; }}
|
||||
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||
>
|
||||
{splitOpen ? 'Hide' : 'Edit'}
|
||||
</button>
|
||||
</div>
|
||||
{#if !splitOpen}
|
||||
<p class="text-xs text-(--color-muted)">
|
||||
This book has a single "Full Text" chapter. Use this tool to split it into chapters.
|
||||
</p>
|
||||
{/if}
|
||||
{#if splitOpen}
|
||||
<p class="text-xs text-(--color-muted)">
|
||||
Insert <code class="bg-(--color-surface-3) px-1 rounded">---</code> on its own line to divide chapters.
|
||||
Optionally start a segment with <code class="bg-(--color-surface-3) px-1 rounded">## Chapter Title</code>.
|
||||
</p>
|
||||
<textarea
|
||||
bind:value={splitText}
|
||||
rows="16"
|
||||
class="w-full px-2 py-1.5 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs font-mono focus:outline-none focus:border-(--color-brand) resize-y"
|
||||
placeholder="Paste or edit the full text here. Use --- to split chapters."
|
||||
></textarea>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
onclick={splitChapters}
|
||||
disabled={splitSaving || !splitText.trim()}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors
|
||||
{splitSaving || !splitText.trim() ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-(--color-brand)/20 text-(--color-brand-dim) hover:bg-(--color-brand)/40 border border-(--color-brand)/30'}"
|
||||
>
|
||||
{#if splitSaving}
|
||||
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
Saving…
|
||||
{:else}
|
||||
Save chapters
|
||||
{/if}
|
||||
</button>
|
||||
{#if splitResult === 'saved'}
|
||||
<span class="text-xs text-green-400">Saved.</span>
|
||||
{:else if splitResult === 'error'}
|
||||
<span class="text-xs text-(--color-danger)">{splitError || 'Error.'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<hr class="border-(--color-border)" />
|
||||
{/if}
|
||||
|
||||
<!-- Rescrape / range-scrape (only for scraped books with a source URL) -->
|
||||
{#if book.source_url}
|
||||
<!-- Rescrape -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
@@ -1065,6 +1173,7 @@
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<hr class="border-(--color-border)" />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user