mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-11-15 16:18:13 +00:00
feat(packs): let users choose which files, books and formats to download (#1480)
## Summary - **Skip files or books in a multi-book pack.** The pack review panel gets a switch per file and a "Skip this book" switch per book, using the shared `ToggleSwitch`. A partly skipped book shows e.g. "3 of 5 files" in the Files column. A fully skipped book is greyed out. - **Choose formats for a single book.** A new "Choose formats" header toggle, next to the multi-book one, opens a format picker for a release that has several book formats (EPUB, MOBI, PDF and so on). It has one switch per format with file count and size. Releases with one format, or that can't be inspected, download as before. - **Skipped files are sent as `ignored_files`.** They are stored on the task (including restart-safe retry) and removed before grouping and transfer. This matters for packs because files missing from `book_plan` would otherwise be filed by the heuristic fallback. Ignored entries match by release-relative path, then path suffix, then unique basename. - A pack that ends up with one book after skips keeps the titles you reviewed instead of falling back to the searched title. ## Screenshots <img width="1277" height="413" alt="Screenshot 2026-10-19 at 09-39-28 Shelfmark" src="https://github.com/user-attachments/assets/c005ca4b-3dac-45dd-b2ff-d02fc0f9fcc9" /> <img width="1277" height="1210" alt="Screenshot 2026-10-19 at 09-40-23 Shelfmark" src="https://github.com/user-attachments/assets/2145f3ba-e5fc-4635-9364-1790feb0c8b8" /> <img width="1277" height="748" alt="Screenshot 2026-10-19 at 09-39-03 Shelfmark" src="https://github.com/user-attachments/assets/39023fac-70e3-46d8-af89-1160ebd6e415" /> ## Test plan - [x] `pytest` pack tests (new `drop_ignored_files` cases added) - [x] `vitest`, `tsc`, `oxlint`, `oxfmt`, `ruff` - [x] Manual check in the UI with a real multi-book pack 🤖 (mostly) Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3c5cfcb77d
commit
51f32b374d
16 files changed
+611
-71
No files matched your search
@@ -143,6 +143,8 @@ class DownloadTask:
|
||||
# `multi_book` asks post-processing to split heuristically when no plan exists.
|
||||
multi_book: bool = False
|
||||
book_plan: list[dict[str, Any]] | None = None
|
||||
# Release-relative paths the user chose to skip in the pack review.
|
||||
ignored_files: list[str] | None = None
|
||||
|
||||
# User association (multi-user support)
|
||||
user_id: int | None = None # DB user ID who queued this download
|
||||
|
||||
@@ -286,6 +286,9 @@ def queue_release(
|
||||
language = release_data.get("language") or extra.get("language")
|
||||
multi_book = bool(release_data.get("multi_book") or extra.get("multi_book"))
|
||||
book_plan = _normalize_book_plan(release_data.get("book_plan") or extra.get("book_plan"))
|
||||
ignored_files = _normalize_ignored_files(
|
||||
release_data.get("ignored_files") or extra.get("ignored_files")
|
||||
)
|
||||
|
||||
books_output_mode = (
|
||||
str(config.get("BOOKS_OUTPUT_MODE", "folder", user_id=user_id) or "folder")
|
||||
@@ -325,6 +328,7 @@ def queue_release(
|
||||
language=language,
|
||||
multi_book=multi_book or book_plan is not None,
|
||||
book_plan=book_plan,
|
||||
ignored_files=ignored_files,
|
||||
search_mode=search_mode,
|
||||
output_mode=output_mode,
|
||||
output_args=output_args,
|
||||
@@ -470,6 +474,14 @@ def can_retry_download_task(
|
||||
return _has_staged_retry_source(task)
|
||||
|
||||
|
||||
def _normalize_ignored_files(value: object) -> list[str] | None:
|
||||
"""Keep only non-empty string paths from a user-ignored file list."""
|
||||
if not isinstance(value, list):
|
||||
return None
|
||||
files = [f for f in value if isinstance(f, str) and f.strip()]
|
||||
return files or None
|
||||
|
||||
|
||||
def _normalize_book_plan(value: object) -> list[dict[str, Any]] | None:
|
||||
"""Keep only well-formed pack books: a title plus a non-empty list of file paths."""
|
||||
if not isinstance(value, list):
|
||||
@@ -529,6 +541,7 @@ def serialize_task_for_retry(task: DownloadTask) -> dict[str, Any]:
|
||||
"search_mode": search_mode,
|
||||
"multi_book": bool(getattr(task, "multi_book", False)),
|
||||
"book_plan": _normalize_book_plan(getattr(task, "book_plan", None)),
|
||||
"ignored_files": _normalize_ignored_files(getattr(task, "ignored_files", None)),
|
||||
"output_mode": getattr(task, "output_mode", None),
|
||||
"output_args": dict(raw_output_args) if isinstance(raw_output_args, dict) else {},
|
||||
"user_id": getattr(task, "user_id", None),
|
||||
@@ -591,6 +604,7 @@ def _restore_task_from_retry_payload(payload: object) -> DownloadTask | None:
|
||||
search_mode=search_mode,
|
||||
multi_book=bool(payload.get("multi_book", False)),
|
||||
book_plan=_normalize_book_plan(payload.get("book_plan")),
|
||||
ignored_files=_normalize_ignored_files(payload.get("ignored_files")),
|
||||
output_mode=normalize_optional_text(payload.get("output_mode")),
|
||||
output_args=dict(output_args) if isinstance(output_args, dict) else {},
|
||||
user_id=normalize_positive_int(payload.get("user_id")),
|
||||
|
||||
@@ -96,6 +96,7 @@ def process_folder_output(
|
||||
preserve_source_on_failure: bool = False,
|
||||
) -> str | None:
|
||||
"""Post-process download to the configured folder destination."""
|
||||
from shelfmark.download.postprocess.packs import drop_ignored_files
|
||||
from shelfmark.download.postprocess.pipeline import (
|
||||
CustomScriptContext,
|
||||
CustomScriptTransferSummary,
|
||||
@@ -262,10 +263,12 @@ def process_folder_output(
|
||||
)
|
||||
|
||||
pack_groups = resolve_book_groups(
|
||||
task, prepared.files, organization_mode=plan.organization_mode
|
||||
task,
|
||||
drop_ignored_files(prepared.files, task.ignored_files),
|
||||
organization_mode=plan.organization_mode,
|
||||
)
|
||||
if pack_groups is not None:
|
||||
message = f"Complete ({len(pack_groups)} books, {len(final_paths)} files)"
|
||||
message = f"Complete ({len(pack_groups)} {'book' if len(pack_groups) == 1 else 'books'}, {len(final_paths)} files)"
|
||||
elif len(final_paths) == 1:
|
||||
message = "Complete"
|
||||
else:
|
||||
|
||||
@@ -357,6 +357,34 @@ def group_files_into_books(
|
||||
]
|
||||
|
||||
|
||||
def drop_ignored_files(book_files: list[Path], ignored: list[str] | None) -> list[Path]:
|
||||
"""Remove files the user chose to skip in the pack review.
|
||||
|
||||
Ignored entries are release-relative paths. A file matches by relative path first,
|
||||
then by one path being a suffix of the other (the root can shift after extraction),
|
||||
then by basename when that basename is unique among the files.
|
||||
"""
|
||||
if not book_files or not ignored:
|
||||
return book_files
|
||||
_root, rel_by_path = _relative_paths(book_files)
|
||||
wanted = {i.replace("\\", "/").lstrip("./") for i in ignored if i and i.strip()}
|
||||
name_counts: dict[str, int] = {}
|
||||
for path in book_files:
|
||||
name_counts[path.name] = name_counts.get(path.name, 0) + 1
|
||||
|
||||
def is_ignored(path: Path) -> bool:
|
||||
rel = rel_by_path[path]
|
||||
if rel in wanted:
|
||||
return True
|
||||
if any(w.endswith("/" + rel) or rel.endswith("/" + w) for w in wanted):
|
||||
return True
|
||||
return name_counts[path.name] == 1 and any(
|
||||
PurePosixPath(w).name == path.name for w in wanted
|
||||
)
|
||||
|
||||
return [p for p in book_files if not is_ignored(p)]
|
||||
|
||||
|
||||
def match_plan_to_files(
|
||||
plan: list[PackBook],
|
||||
book_files: list[Path],
|
||||
|
||||
@@ -31,7 +31,13 @@ from shelfmark.download.postprocess.policy import (
|
||||
get_word_separator,
|
||||
)
|
||||
|
||||
from .packs import BookGroup, PackBook, group_files_into_books, match_plan_to_files
|
||||
from .packs import (
|
||||
BookGroup,
|
||||
PackBook,
|
||||
drop_ignored_files,
|
||||
group_files_into_books,
|
||||
match_plan_to_files,
|
||||
)
|
||||
from .scan import collect_directory_files, scan_directory_tree
|
||||
from .types import TransferPlan
|
||||
from .workspace import safe_cleanup_path
|
||||
@@ -198,6 +204,7 @@ def transfer_book_files(
|
||||
source_root: Path | None = None,
|
||||
) -> tuple[list[Path], str | None, dict[str, int]]:
|
||||
"""Transfer discovered book files into their final destination layout."""
|
||||
book_files = drop_ignored_files(book_files, task.ignored_files)
|
||||
if not book_files:
|
||||
return [], "No book files found", {"hardlink": 0, "copy": 0, "move": 0}
|
||||
|
||||
@@ -356,7 +363,10 @@ def resolve_book_groups(
|
||||
groups = group_files_into_books(
|
||||
book_files, series_name=task.series_name, author_name=task.author
|
||||
)
|
||||
return groups if len(groups) > 1 else None
|
||||
# An approved plan that lost books to the user's skips still carries the titles they
|
||||
# reviewed, so a single remaining group keeps them instead of the searched title.
|
||||
keeps_plan = bool(task.book_plan and task.ignored_files)
|
||||
return groups if len(groups) > 1 or (keeps_plan and groups) else None
|
||||
|
||||
|
||||
def _transfer_book_groups(
|
||||
@@ -388,6 +398,7 @@ def _transfer_book_groups(
|
||||
series_position=group.series_position,
|
||||
multi_book=False,
|
||||
book_plan=None,
|
||||
ignored_files=None,
|
||||
)
|
||||
paths, error, op_counts = transfer_book_files(
|
||||
group.files,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { Release } from '../types';
|
||||
import { formatBytes, ignoredForFormats, type FormatGroup } from '../utils/formatReview';
|
||||
import { ToggleSwitch } from './shared/ToggleSwitch';
|
||||
|
||||
interface FormatReviewPanelProps {
|
||||
release: Release;
|
||||
groups: FormatGroup[];
|
||||
onBack: () => void;
|
||||
/** Release-relative paths of the files in every format the user switched off. */
|
||||
onConfirm: (ignoredFiles: string[]) => Promise<void>;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export const FormatReviewPanel = ({
|
||||
release,
|
||||
groups,
|
||||
onBack,
|
||||
onConfirm,
|
||||
isSubmitting,
|
||||
}: FormatReviewPanelProps) => {
|
||||
const [selected, setSelected] = useState<ReadonlySet<string>>(
|
||||
new Set(groups.map((group) => group.format)),
|
||||
);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
const toggleFormat = (format: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(format)) next.delete(format);
|
||||
else next.add(format);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const fileCount = groups
|
||||
.filter((group) => selected.has(group.format))
|
||||
.reduce((sum, group) => sum + group.files.length, 0);
|
||||
const canConfirm = !isSubmitting && selected.size > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-5 py-4" data-testid="format-review-panel">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-(--text)">Choose formats to download</h3>
|
||||
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
<span className="font-medium text-(--text)">{release.title}</span> comes in several
|
||||
formats. Switch off any you don’t want; they won’t be filed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col divide-y divide-zinc-200/60 dark:divide-zinc-800/60">
|
||||
{groups.map((group) => {
|
||||
const included = selected.has(group.format);
|
||||
const size = formatBytes(group.totalBytes);
|
||||
return (
|
||||
<div key={group.format} className={`py-2 ${included ? '' : 'opacity-50'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<ToggleSwitch
|
||||
checked={included}
|
||||
onChange={() => toggleFormat(group.format)}
|
||||
color="emerald"
|
||||
ariaLabel={`Include ${group.format || 'unknown'} format`}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<span className="w-16 text-sm font-medium text-(--text) uppercase">
|
||||
{group.format || 'other'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(expanded === group.format ? null : group.format)}
|
||||
className="hover-surface ml-auto rounded-md px-2 py-1 text-sm text-zinc-500 transition-colors dark:text-zinc-400"
|
||||
aria-expanded={expanded === group.format}
|
||||
>
|
||||
{group.files.length} {group.files.length === 1 ? 'file' : 'files'}
|
||||
{size && ` · ${size}`}
|
||||
</button>
|
||||
</div>
|
||||
{expanded === group.format && (
|
||||
<ul className="mt-2 max-h-40 overflow-y-auto rounded-md bg-(--bg-soft) px-3 py-2 font-mono text-xs break-all text-zinc-600 dark:text-zinc-300">
|
||||
{group.files.map((file) => (
|
||||
<li key={file.path}>{file.path}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 border-t border-(--border-muted) pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isSubmitting}
|
||||
className="hover-surface rounded-lg px-3 py-1.5 text-sm font-medium text-(--text) transition-colors disabled:opacity-50"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onConfirm(ignoredForFormats(groups, selected))}
|
||||
disabled={!canConfirm}
|
||||
className="rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting
|
||||
? 'Queuing…'
|
||||
: `Download ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,8 +2,10 @@ import { useState } from 'react';
|
||||
|
||||
import type { PackBook, PackPlan, Release } from '../types';
|
||||
import {
|
||||
applyIgnoredFiles,
|
||||
describePackPlan,
|
||||
parseSeriesPositionInput,
|
||||
setBookIgnored,
|
||||
toBookPlanPayload,
|
||||
updateReviewBook,
|
||||
} from '../utils/packReview';
|
||||
@@ -16,7 +18,7 @@ interface PackReviewPanelProps {
|
||||
onChange: (books: PackBook[]) => void;
|
||||
onBack: () => void;
|
||||
/** `null` means "treat the whole release as one book". */
|
||||
onConfirm: (books: PackBook[] | null) => Promise<void>;
|
||||
onConfirm: (books: PackBook[] | null, ignoredFiles?: string[]) => Promise<void>;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
@@ -35,8 +37,18 @@ export const PackReviewPanel = ({
|
||||
const [singleBook, setSingleBook] = useState(false);
|
||||
const [expandedFiles, setExpandedFiles] = useState<number | null>(null);
|
||||
const [showIgnored, setShowIgnored] = useState(false);
|
||||
const [userIgnored, setUserIgnored] = useState<ReadonlySet<string>>(new Set());
|
||||
|
||||
const payloadBooks = toBookPlanPayload(books);
|
||||
const toggleFile = (file: string) => {
|
||||
setUserIgnored((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(file)) next.delete(file);
|
||||
else next.add(file);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const payloadBooks = toBookPlanPayload(applyIgnoredFiles(books, userIgnored));
|
||||
const canConfirm = !isSubmitting && (singleBook || payloadBooks.length > 0);
|
||||
const confirmLabel = singleBook
|
||||
? 'Download as one book'
|
||||
@@ -50,7 +62,10 @@ export const PackReviewPanel = ({
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
<span className="font-medium text-(--text)">{release.title}</span> ·{' '}
|
||||
{describePackPlan(books, plan.ignored)}
|
||||
{describePackPlan(applyIgnoredFiles(books, userIgnored), [
|
||||
...plan.ignored,
|
||||
...userIgnored,
|
||||
])}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Each book below is filed separately with its own title. Fix any titles before downloading
|
||||
@@ -85,68 +100,110 @@ export const PackReviewPanel = ({
|
||||
<span>Year</span>
|
||||
<span className="text-right">Files</span>
|
||||
</div>
|
||||
{books.map((book, index) => (
|
||||
<div key={book.files[0] ?? index} className="py-2">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_72px_72px_80px] items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={book.title}
|
||||
onChange={(e) =>
|
||||
onChange(updateReviewBook(books, index, { title: e.target.value }))
|
||||
}
|
||||
aria-label={`Title for book ${index + 1}`}
|
||||
className={inputClassName}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={book.series_position ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
updateReviewBook(books, index, {
|
||||
series_position: parseSeriesPositionInput(e.target.value),
|
||||
}),
|
||||
)
|
||||
}
|
||||
aria-label={`Series position for book ${index + 1}`}
|
||||
className={inputClassName}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={book.year ?? ''}
|
||||
onChange={(e) => {
|
||||
const parsed = parseSeriesPositionInput(e.target.value);
|
||||
onChange(
|
||||
updateReviewBook(books, index, {
|
||||
year: parsed === null ? null : Math.trunc(parsed),
|
||||
}),
|
||||
);
|
||||
}}
|
||||
aria-label={`Year for book ${index + 1}`}
|
||||
className={inputClassName}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedFiles(expandedFiles === index ? null : index)}
|
||||
className="hover-surface rounded-md px-2 py-1 text-right text-sm text-zinc-500 transition-colors dark:text-zinc-400"
|
||||
aria-expanded={expandedFiles === index}
|
||||
>
|
||||
{book.files.length} {book.files.length === 1 ? 'file' : 'files'}
|
||||
</button>
|
||||
{books.map((book, index) => {
|
||||
const skipped = book.files.every((file) => userIgnored.has(file));
|
||||
const ignoredCount = book.files.filter((file) => userIgnored.has(file)).length;
|
||||
const partiallyIgnored = !skipped && ignoredCount > 0;
|
||||
const fileNoun = book.files.length === 1 ? 'file' : 'files';
|
||||
return (
|
||||
<div
|
||||
key={book.files[0] ?? index}
|
||||
className={`py-2 ${skipped ? 'opacity-50' : ''}`}
|
||||
data-skipped={skipped}
|
||||
>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_72px_72px_80px] items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={book.title}
|
||||
onChange={(e) =>
|
||||
onChange(updateReviewBook(books, index, { title: e.target.value }))
|
||||
}
|
||||
aria-label={`Title for book ${index + 1}`}
|
||||
className={inputClassName}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={book.series_position ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
updateReviewBook(books, index, {
|
||||
series_position: parseSeriesPositionInput(e.target.value),
|
||||
}),
|
||||
)
|
||||
}
|
||||
aria-label={`Series position for book ${index + 1}`}
|
||||
className={inputClassName}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={book.year ?? ''}
|
||||
onChange={(e) => {
|
||||
const parsed = parseSeriesPositionInput(e.target.value);
|
||||
onChange(
|
||||
updateReviewBook(books, index, {
|
||||
year: parsed === null ? null : Math.trunc(parsed),
|
||||
}),
|
||||
);
|
||||
}}
|
||||
aria-label={`Year for book ${index + 1}`}
|
||||
className={inputClassName}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedFiles(expandedFiles === index ? null : index)}
|
||||
className="hover-surface rounded-md px-2 py-1 text-right text-sm text-zinc-500 transition-colors dark:text-zinc-400"
|
||||
aria-expanded={expandedFiles === index}
|
||||
>
|
||||
{partiallyIgnored ? (
|
||||
<span
|
||||
className="text-amber-600 dark:text-amber-400"
|
||||
title={`${ignoredCount} of ${book.files.length} ${fileNoun} skipped`}
|
||||
>
|
||||
{book.files.length - ignoredCount} of {book.files.length} {fileNoun}
|
||||
</span>
|
||||
) : (
|
||||
`${book.files.length} ${fileNoun}`
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
<ToggleSwitch
|
||||
checked={skipped}
|
||||
onChange={(checked) => setUserIgnored(setBookIgnored(userIgnored, book, checked))}
|
||||
color="emerald"
|
||||
ariaLabel={`Skip book ${index + 1}`}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
Skip this book
|
||||
</div>
|
||||
{expandedFiles === index && (
|
||||
<ul className="mt-2 max-h-40 overflow-y-auto rounded-md bg-(--bg-soft) px-3 py-2 font-mono text-xs break-all text-zinc-600 dark:text-zinc-300">
|
||||
{book.files.map((file) => (
|
||||
<li key={file}>
|
||||
<div className="flex items-center gap-2 py-0.5">
|
||||
<ToggleSwitch
|
||||
checked={!userIgnored.has(file)}
|
||||
onChange={() => toggleFile(file)}
|
||||
color="emerald"
|
||||
ariaLabel={`Include ${file}`}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<span className={userIgnored.has(file) ? 'line-through opacity-60' : ''}>
|
||||
{file}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{expandedFiles === index && (
|
||||
<ul className="mt-2 max-h-40 overflow-y-auto rounded-md bg-(--bg-soft) px-3 py-2 font-mono text-xs break-all text-zinc-600 dark:text-zinc-300">
|
||||
{book.files.map((file) => (
|
||||
<li key={file}>{file}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{plan.ignored.length > 0 && (
|
||||
@@ -181,7 +238,7 @@ export const PackReviewPanel = ({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onConfirm(singleBook ? null : payloadBooks)}
|
||||
onClick={() => void onConfirm(singleBook ? null : payloadBooks, [...userIgnored])}
|
||||
disabled={!canConfirm}
|
||||
className="rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
|
||||
@@ -20,11 +20,13 @@ import type {
|
||||
ContentType,
|
||||
RequestPolicyMode,
|
||||
PackBook,
|
||||
InspectReleaseResponse,
|
||||
PackPlan,
|
||||
} from '../types';
|
||||
import { isMetadataBook } from '../types';
|
||||
import { bookSupportsTargets } from '../utils/bookTargetLoader';
|
||||
import { getColorStyleFromHint } from '../utils/colorMaps';
|
||||
import { groupFilesByFormat, type FormatGroup } from '../utils/formatReview';
|
||||
import {
|
||||
LANGUAGE_OPTION_DEFAULT,
|
||||
getLanguageFilterValues,
|
||||
@@ -55,6 +57,7 @@ import { BookDownloadButton } from './BookDownloadButton';
|
||||
import { BookTargetDropdown } from './BookTargetDropdown';
|
||||
import { Dropdown } from './Dropdown';
|
||||
import { DropdownList } from './DropdownList';
|
||||
import { FormatReviewPanel } from './FormatReviewPanel';
|
||||
import { LanguageMultiSelect } from './LanguageMultiSelect';
|
||||
import { PackReviewPanel } from './PackReviewPanel';
|
||||
import { ReleaseCell } from './ReleaseCell';
|
||||
@@ -788,6 +791,13 @@ const ReleaseModalSession = ({
|
||||
books: PackBook[];
|
||||
} | null>(null);
|
||||
const [packSubmitting, setPackSubmitting] = useState(false);
|
||||
// Format picker: `chooseFormats` is the header toggle; `formatReview` holds an inspected
|
||||
// release with several formats awaiting the user's selection.
|
||||
const [chooseFormats, setChooseFormats] = useState(false);
|
||||
const [formatReview, setFormatReview] = useState<{
|
||||
release: Release;
|
||||
groups: FormatGroup[];
|
||||
} | null>(null);
|
||||
const isCombinedMode = combinedMode != null;
|
||||
const combinedPhase = combinedMode?.phase ?? null;
|
||||
const combinedStepLabel = combinedMode?.stepLabel ?? '';
|
||||
@@ -1233,6 +1243,7 @@ const ReleaseModalSession = ({
|
||||
// reviewed and filed as separate books instead of one mangled item.
|
||||
let inspected = false;
|
||||
let plan: PackPlan | null = null;
|
||||
let inspectedFiles: InspectReleaseResponse['files'] = [];
|
||||
let reason: string | null = null;
|
||||
try {
|
||||
const inspection = await inspectRelease(
|
||||
@@ -1240,6 +1251,7 @@ const ReleaseModalSession = ({
|
||||
);
|
||||
inspected = inspection.inspected;
|
||||
plan = inspection.plan;
|
||||
inspectedFiles = inspection.files;
|
||||
reason = inspection.reason;
|
||||
} catch (error) {
|
||||
console.error('Release inspection failed:', error);
|
||||
@@ -1248,6 +1260,16 @@ const ReleaseModalSession = ({
|
||||
setPackReview({ release, plan, books: plan.books });
|
||||
return;
|
||||
}
|
||||
if (inspected && chooseFormats && plan) {
|
||||
const groups = groupFilesByFormat(
|
||||
inspectedFiles,
|
||||
new Set(plan.books.flatMap((planBook) => planBook.files)),
|
||||
);
|
||||
if (groups.length > 1) {
|
||||
setFormatReview({ release, groups });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Not a pack (or couldn't be inspected): queue exactly as before. A release we
|
||||
// couldn't inspect might still be an unnoticed pack, so leave a console breadcrumb
|
||||
// rather than interrupting the user; the multi-book toggle forces the split.
|
||||
@@ -1278,11 +1300,29 @@ const ReleaseModalSession = ({
|
||||
contentType,
|
||||
handleClose,
|
||||
multiBook,
|
||||
chooseFormats,
|
||||
],
|
||||
);
|
||||
|
||||
const handleFormatConfirm = useCallback(
|
||||
async (ignoredFiles: string[]): Promise<void> => {
|
||||
if (!book || !formatReview) {
|
||||
return;
|
||||
}
|
||||
setPackSubmitting(true);
|
||||
try {
|
||||
await onDownload(book, formatReview.release, contentType, { ignoredFiles });
|
||||
handleClose();
|
||||
} finally {
|
||||
setPackSubmitting(false);
|
||||
}
|
||||
},
|
||||
// oxlint-disable-next-line react/memo-dependencies
|
||||
[book, formatReview, onDownload, contentType, handleClose],
|
||||
);
|
||||
|
||||
const handlePackConfirm = useCallback(
|
||||
async (books: PackBook[] | null): Promise<void> => {
|
||||
async (books: PackBook[] | null, ignoredFiles: string[] = []): Promise<void> => {
|
||||
if (!book || !packReview) {
|
||||
return;
|
||||
}
|
||||
@@ -1292,7 +1332,7 @@ const ReleaseModalSession = ({
|
||||
book,
|
||||
packReview.release,
|
||||
contentType,
|
||||
books ? { multiBook: true, bookPlan: toBookPlanPayload(books) } : {},
|
||||
books ? { multiBook: true, bookPlan: toBookPlanPayload(books), ignoredFiles } : {},
|
||||
);
|
||||
handleClose();
|
||||
} finally {
|
||||
@@ -1871,6 +1911,37 @@ const ReleaseModalSession = ({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Format picker toggle: choose which formats of a release to download */}
|
||||
{!isCombinedMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChooseFormats((prev) => !prev)}
|
||||
className={`hover-surface relative rounded-full p-2.5 text-zinc-500 transition-colors dark:text-zinc-400 ${
|
||||
chooseFormats ? 'text-emerald-600 dark:text-emerald-400' : ''
|
||||
}`}
|
||||
aria-label="Choose formats"
|
||||
aria-pressed={chooseFormats}
|
||||
title="Choose formats: when a release has several formats, pick which ones to download."
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
|
||||
/>
|
||||
</svg>
|
||||
{chooseFormats && (
|
||||
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-emerald-500" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Manual query button */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -2271,6 +2342,17 @@ const ReleaseModalSession = ({
|
||||
{/* Release list content */}
|
||||
<div className="min-h-[200px]">
|
||||
{(() => {
|
||||
if (formatReview) {
|
||||
return (
|
||||
<FormatReviewPanel
|
||||
release={formatReview.release}
|
||||
groups={formatReview.groups}
|
||||
onBack={() => setFormatReview(null)}
|
||||
onConfirm={handleFormatConfirm}
|
||||
isSubmitting={packSubmitting}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (packReview) {
|
||||
return (
|
||||
<PackReviewPanel
|
||||
|
||||
@@ -562,6 +562,7 @@ export type DownloadReleasePayload = {
|
||||
search_mode?: 'direct' | 'universal';
|
||||
multi_book?: boolean; // Split a multi-book pack into one book per subfolder/file
|
||||
book_plan?: PackBook[]; // The split the user approved before download
|
||||
ignored_files?: string[]; // Pack files the user chose to skip
|
||||
};
|
||||
|
||||
/** Inspect a release's file list before download (same body as downloadRelease). */
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
fileFormat,
|
||||
formatBytes,
|
||||
groupFilesByFormat,
|
||||
ignoredForFormats,
|
||||
type ReviewFile,
|
||||
} from '../utils/formatReview';
|
||||
|
||||
const files: ReviewFile[] = [
|
||||
{ path: 'Book/Book.epub', size: 1000 },
|
||||
{ path: 'Book/Book.mobi', size: 2000 },
|
||||
{ path: 'Book/Book.pdf', size: null },
|
||||
{ path: 'Book/Book2.epub', size: 500 },
|
||||
{ path: 'Book/cover.jpg', size: 10 },
|
||||
];
|
||||
const bookFiles = new Set(files.filter((f) => !f.path.endsWith('.jpg')).map((f) => f.path));
|
||||
|
||||
describe('formatReview.fileFormat', () => {
|
||||
it('lowercases the extension and handles missing ones', () => {
|
||||
expect(fileFormat('a/B.EPUB')).toBe('epub');
|
||||
expect(fileFormat('a\\b\\c.m4b')).toBe('m4b');
|
||||
expect(fileFormat('README')).toBe('');
|
||||
expect(fileFormat('.hidden')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatReview.groupFilesByFormat', () => {
|
||||
it('groups only book files, biggest group first, summing known sizes', () => {
|
||||
const groups = groupFilesByFormat(files, bookFiles);
|
||||
expect(groups.map((g) => g.format)).toEqual(['epub', 'mobi', 'pdf']);
|
||||
expect(groups[0].totalBytes).toBe(1500);
|
||||
expect(groups[2].totalBytes).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatReview.ignoredForFormats', () => {
|
||||
it('returns every file of unselected formats', () => {
|
||||
const groups = groupFilesByFormat(files, bookFiles);
|
||||
expect(ignoredForFormats(groups, new Set(['epub']))).toEqual([
|
||||
'Book/Book.mobi',
|
||||
'Book/Book.pdf',
|
||||
]);
|
||||
expect(ignoredForFormats(groups, new Set(['epub', 'mobi', 'pdf']))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatReview.formatBytes', () => {
|
||||
it('formats sizes and tolerates unknown', () => {
|
||||
expect(formatBytes(null)).toBe('');
|
||||
expect(formatBytes(512)).toBe('512 B');
|
||||
expect(formatBytes(1536)).toBe('1.5 KB');
|
||||
expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB');
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,10 @@ import { describe, it, expect } from 'vitest';
|
||||
|
||||
import type { PackBook } from '../types';
|
||||
import {
|
||||
applyIgnoredFiles,
|
||||
describePackPlan,
|
||||
parseSeriesPositionInput,
|
||||
setBookIgnored,
|
||||
toBookPlanPayload,
|
||||
updateReviewBook,
|
||||
} from '../utils/packReview';
|
||||
@@ -51,3 +53,24 @@ describe('packReview.describePackPlan', () => {
|
||||
expect(describePackPlan([books[0]], [])).toBe('1 book · 1 file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('packReview.applyIgnoredFiles', () => {
|
||||
it('removes ignored files and drops books left empty', () => {
|
||||
const result = applyIgnoredFiles(books, new Set(['b2.m4b', 'a.m4b']));
|
||||
expect(result).toEqual([{ ...books[1], files: ['b.m4b'] }]);
|
||||
});
|
||||
|
||||
it('returns the input untouched when nothing is ignored', () => {
|
||||
expect(applyIgnoredFiles(books, new Set())).toBe(books);
|
||||
});
|
||||
});
|
||||
|
||||
describe('packReview.setBookIgnored', () => {
|
||||
it('adds and removes every file of a book without mutating the input', () => {
|
||||
const start = new Set<string>();
|
||||
const added = setBookIgnored(start, books[1], true);
|
||||
expect([...added].toSorted()).toEqual(['b.m4b', 'b2.m4b']);
|
||||
expect(start.size).toBe(0);
|
||||
expect(setBookIgnored(added, books[1], false).size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
export interface ReviewFile {
|
||||
path: string;
|
||||
size: number | null;
|
||||
}
|
||||
|
||||
export interface FormatGroup {
|
||||
/** Lowercase extension without the dot, e.g. "epub". */
|
||||
format: string;
|
||||
files: ReviewFile[];
|
||||
/** Sum of known file sizes in bytes; null when no file reports a size. */
|
||||
totalBytes: number | null;
|
||||
}
|
||||
|
||||
/** The lowercase extension of a release-relative path ("" when it has none). */
|
||||
export function fileFormat(path: string): string {
|
||||
const name = path.split(/[\\/]/).pop() ?? '';
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
|
||||
}
|
||||
|
||||
/** Group the release's book files by format, largest group first. */
|
||||
export function groupFilesByFormat(
|
||||
files: ReviewFile[],
|
||||
bookFiles: ReadonlySet<string>,
|
||||
): FormatGroup[] {
|
||||
const groups = new Map<string, FormatGroup>();
|
||||
for (const file of files) {
|
||||
if (!bookFiles.has(file.path)) continue;
|
||||
const format = fileFormat(file.path);
|
||||
const group = groups.get(format) ?? { format, files: [], totalBytes: null };
|
||||
group.files.push(file);
|
||||
if (file.size !== null) group.totalBytes = (group.totalBytes ?? 0) + file.size;
|
||||
groups.set(format, group);
|
||||
}
|
||||
return [...groups.values()].toSorted(
|
||||
(a, b) => b.files.length - a.files.length || a.format.localeCompare(b.format),
|
||||
);
|
||||
}
|
||||
|
||||
/** Every file belonging to a format that is not selected. */
|
||||
export function ignoredForFormats(groups: FormatGroup[], selected: ReadonlySet<string>): string[] {
|
||||
return groups.filter((g) => !selected.has(g.format)).flatMap((g) => g.files.map((f) => f.path));
|
||||
}
|
||||
|
||||
/** "1.5 MB", "820 KB"; empty string for an unknown size. */
|
||||
export function formatBytes(bytes: number | null): string {
|
||||
if (bytes === null) return '';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
const digits = unit === 0 || value >= 100 ? 0 : 1;
|
||||
return `${value.toFixed(digits)} ${units[unit]}`;
|
||||
}
|
||||
@@ -24,6 +24,28 @@ export function toBookPlanPayload(books: PackBook[]): PackBook[] {
|
||||
.filter((book) => book.title.length > 0 && book.files.length > 0);
|
||||
}
|
||||
|
||||
/** Books with the user-ignored files removed; books left with no files are dropped. */
|
||||
export function applyIgnoredFiles(books: PackBook[], ignored: ReadonlySet<string>): PackBook[] {
|
||||
if (ignored.size === 0) return books;
|
||||
return books
|
||||
.map((book) => ({ ...book, files: book.files.filter((file) => !ignored.has(file)) }))
|
||||
.filter((book) => book.files.length > 0);
|
||||
}
|
||||
|
||||
/** Add or remove every file of a book from the ignored set (returns a new set). */
|
||||
export function setBookIgnored(
|
||||
ignored: ReadonlySet<string>,
|
||||
book: PackBook,
|
||||
shouldIgnore: boolean,
|
||||
): Set<string> {
|
||||
const next = new Set(ignored);
|
||||
for (const file of book.files) {
|
||||
if (shouldIgnore) next.add(file);
|
||||
else next.delete(file);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function plural(count: number, noun: string): string {
|
||||
return `${count} ${noun}${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface ReleaseDownloadOptions {
|
||||
multiBook?: boolean;
|
||||
/** The split the user approved in the pack review panel. */
|
||||
bookPlan?: PackBook[];
|
||||
/** Files from the pack the user chose to skip (release-relative paths). */
|
||||
ignoredFiles?: string[];
|
||||
}
|
||||
|
||||
/** Download count for a release, preferring what the release itself reports. */
|
||||
@@ -69,5 +71,8 @@ export function buildReleaseDownloadPayload(
|
||||
if (options.bookPlan) {
|
||||
payload.book_plan = options.bookPlan;
|
||||
}
|
||||
if (options.ignoredFiles && options.ignoredFiles.length > 0) {
|
||||
payload.ignored_files = options.ignoredFiles;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
@@ -210,3 +210,48 @@ def test_single_group_with_multi_book_flag_uses_searched_title(tmp_path):
|
||||
assert result is not None
|
||||
assert (ingest / "James S. A. Corey" / "Drive" / "Drive.m4b").exists()
|
||||
assert statuses[-1] == ("complete", "Complete")
|
||||
|
||||
|
||||
def test_ignored_book_is_not_filed_even_though_the_plan_omits_it(tmp_path):
|
||||
staging = tmp_path / "staging"
|
||||
ingest = tmp_path / "ingest"
|
||||
staging.mkdir()
|
||||
ingest.mkdir()
|
||||
pack = _nested_pack(tmp_path)
|
||||
|
||||
task = _audiobook_task(
|
||||
book_plan=[
|
||||
{
|
||||
"title": "Empire of Silence",
|
||||
"series_position": 1.0,
|
||||
"year": None,
|
||||
"files": ["Book 1 - Empire of Silence/empire.m4b"],
|
||||
}
|
||||
],
|
||||
ignored_files=["Book 2 - Howling Dark/howling.m4b"],
|
||||
)
|
||||
|
||||
result, _statuses = _run(pack, task, ingest, staging)
|
||||
|
||||
assert result is not None
|
||||
filed = sorted(p.name for p in ingest.rglob("*.m4b"))
|
||||
assert filed == ["Empire of Silence.m4b"]
|
||||
|
||||
|
||||
def test_ignored_file_is_skipped_from_a_single_remaining_book(tmp_path):
|
||||
staging = tmp_path / "staging"
|
||||
ingest = tmp_path / "ingest"
|
||||
staging.mkdir()
|
||||
ingest.mkdir()
|
||||
pack = _nested_pack(tmp_path)
|
||||
|
||||
task = _audiobook_task(
|
||||
multi_book=True,
|
||||
ignored_files=["Book 2 - Howling Dark/howling.m4b"],
|
||||
)
|
||||
|
||||
result, _statuses = _run(pack, task, ingest, staging)
|
||||
|
||||
assert result is not None
|
||||
assert [p.name for p in ingest.rglob("*.m4b")] != []
|
||||
assert not any("howling" in p.name.lower() for p in ingest.rglob("*"))
|
||||
@@ -383,3 +383,24 @@ class TestMatchPlanToFiles:
|
||||
assert set(groups[0].files) == {b1_t1, b1_t2}
|
||||
assert groups[1].title == "Book 2"
|
||||
assert set(groups[1].files) == {b2_t1, b2_t2}
|
||||
|
||||
|
||||
class TestDropIgnoredFiles:
|
||||
def test_drops_by_relative_path_and_keeps_others(self, tmp_path: Path):
|
||||
from shelfmark.download.postprocess.packs import drop_ignored_files
|
||||
|
||||
files = [tmp_path / "A" / "a.m4b", tmp_path / "A" / "a.epub", tmp_path / "B" / "b.m4b"]
|
||||
assert drop_ignored_files(files, ["A/a.epub"]) == [files[0], files[2]]
|
||||
|
||||
def test_basename_fallback_only_when_unique(self, tmp_path: Path):
|
||||
from shelfmark.download.postprocess.packs import drop_ignored_files
|
||||
|
||||
files = [tmp_path / "A" / "01.mp3", tmp_path / "B" / "01.mp3", tmp_path / "B" / "x.mp3"]
|
||||
assert drop_ignored_files(files, ["x.mp3"]) == files[:2]
|
||||
assert drop_ignored_files(files, ["Z/01.mp3"]) == files
|
||||
|
||||
def test_no_ignored_is_a_noop(self, tmp_path: Path):
|
||||
from shelfmark.download.postprocess.packs import drop_ignored_files
|
||||
|
||||
files = [tmp_path / "a.m4b"]
|
||||
assert drop_ignored_files(files, None) is files
|
||||
Reference in new issue
Block a user