From 51f32b374d035bb856ddafa43422b6864987d830 Mon Sep 17 00:00:00 2001 From: amura11 <1559993+amura11@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:03:05 -0600 Subject: [PATCH] feat(packs): let users choose which files, books and formats to download (#1480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 Screenshot 2026-10-19 at 09-39-28
Shelfmark Screenshot 2026-10-19 at 09-40-23
Shelfmark Screenshot 2026-10-19 at 09-39-03
Shelfmark ## 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 --- shelfmark/core/models.py | 2 + shelfmark/download/orchestrator.py | 14 ++ shelfmark/download/outputs/folder.py | 7 +- shelfmark/download/postprocess/packs.py | 28 +++ shelfmark/download/postprocess/transfer.py | 15 +- .../src/components/FormatReviewPanel.tsx | 113 +++++++++++ .../src/components/PackReviewPanel.tsx | 187 ++++++++++++------ src/frontend/src/components/ReleaseModal.tsx | 86 +++++++- src/frontend/src/services/api.ts | 1 + src/frontend/src/tests/formatReview.test.ts | 56 ++++++ src/frontend/src/tests/packReview.test.ts | 23 +++ src/frontend/src/utils/formatReview.ts | 57 ++++++ src/frontend/src/utils/packReview.ts | 22 +++ src/frontend/src/utils/releasePayload.ts | 5 + tests/core/test_processing_packs.py | 45 +++++ tests/download/test_packs.py | 21 ++ 16 files changed, 611 insertions(+), 71 deletions(-) create mode 100644 src/frontend/src/components/FormatReviewPanel.tsx create mode 100644 src/frontend/src/tests/formatReview.test.ts create mode 100644 src/frontend/src/utils/formatReview.ts diff --git a/shelfmark/core/models.py b/shelfmark/core/models.py index 5aafc2eb..90a427a3 100644 --- a/shelfmark/core/models.py +++ b/shelfmark/core/models.py @@ -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 diff --git a/shelfmark/download/orchestrator.py b/shelfmark/download/orchestrator.py index e37e99ae..13371a30 100644 --- a/shelfmark/download/orchestrator.py +++ b/shelfmark/download/orchestrator.py @@ -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")), diff --git a/shelfmark/download/outputs/folder.py b/shelfmark/download/outputs/folder.py index 3f549d35..8cc91105 100644 --- a/shelfmark/download/outputs/folder.py +++ b/shelfmark/download/outputs/folder.py @@ -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: diff --git a/shelfmark/download/postprocess/packs.py b/shelfmark/download/postprocess/packs.py index 5cd97726..0ca2b0b8 100644 --- a/shelfmark/download/postprocess/packs.py +++ b/shelfmark/download/postprocess/packs.py @@ -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], diff --git a/shelfmark/download/postprocess/transfer.py b/shelfmark/download/postprocess/transfer.py index 0be089c9..6a48110e 100644 --- a/shelfmark/download/postprocess/transfer.py +++ b/shelfmark/download/postprocess/transfer.py @@ -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, diff --git a/src/frontend/src/components/FormatReviewPanel.tsx b/src/frontend/src/components/FormatReviewPanel.tsx new file mode 100644 index 00000000..922f0814 --- /dev/null +++ b/src/frontend/src/components/FormatReviewPanel.tsx @@ -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; + isSubmitting: boolean; +} + +export const FormatReviewPanel = ({ + release, + groups, + onBack, + onConfirm, + isSubmitting, +}: FormatReviewPanelProps) => { + const [selected, setSelected] = useState>( + new Set(groups.map((group) => group.format)), + ); + const [expanded, setExpanded] = useState(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 ( +
+
+

Choose formats to download

+

+ {release.title} comes in several + formats. Switch off any you don’t want; they won’t be filed. +

+
+ +
+ {groups.map((group) => { + const included = selected.has(group.format); + const size = formatBytes(group.totalBytes); + return ( +
+
+ toggleFormat(group.format)} + color="emerald" + ariaLabel={`Include ${group.format || 'unknown'} format`} + disabled={isSubmitting} + /> + + {group.format || 'other'} + + +
+ {expanded === group.format && ( +
    + {group.files.map((file) => ( +
  • {file.path}
  • + ))} +
+ )} +
+ ); + })} +
+ +
+ + +
+
+ ); +}; diff --git a/src/frontend/src/components/PackReviewPanel.tsx b/src/frontend/src/components/PackReviewPanel.tsx index 007ccf1a..599c1df0 100644 --- a/src/frontend/src/components/PackReviewPanel.tsx +++ b/src/frontend/src/components/PackReviewPanel.tsx @@ -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; + onConfirm: (books: PackBook[] | null, ignoredFiles?: string[]) => Promise; isSubmitting: boolean; } @@ -35,8 +37,18 @@ export const PackReviewPanel = ({ const [singleBook, setSingleBook] = useState(false); const [expandedFiles, setExpandedFiles] = useState(null); const [showIgnored, setShowIgnored] = useState(false); + const [userIgnored, setUserIgnored] = useState>(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 = ({

{release.title} ·{' '} - {describePackPlan(books, plan.ignored)} + {describePackPlan(applyIgnoredFiles(books, userIgnored), [ + ...plan.ignored, + ...userIgnored, + ])}

Each book below is filed separately with its own title. Fix any titles before downloading @@ -85,68 +100,110 @@ export const PackReviewPanel = ({ Year Files - {books.map((book, index) => ( -

-
- - onChange(updateReviewBook(books, index, { title: e.target.value })) - } - aria-label={`Title for book ${index + 1}`} - className={inputClassName} - disabled={isSubmitting} - /> - - onChange( - updateReviewBook(books, index, { - series_position: parseSeriesPositionInput(e.target.value), - }), - ) - } - aria-label={`Series position for book ${index + 1}`} - className={inputClassName} - disabled={isSubmitting} - /> - { - 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} - /> - + {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 ( +
+
+ + onChange(updateReviewBook(books, index, { title: e.target.value })) + } + aria-label={`Title for book ${index + 1}`} + className={inputClassName} + disabled={isSubmitting} + /> + + onChange( + updateReviewBook(books, index, { + series_position: parseSeriesPositionInput(e.target.value), + }), + ) + } + aria-label={`Series position for book ${index + 1}`} + className={inputClassName} + disabled={isSubmitting} + /> + { + 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} + /> + +
+
+ setUserIgnored(setBookIgnored(userIgnored, book, checked))} + color="emerald" + ariaLabel={`Skip book ${index + 1}`} + disabled={isSubmitting} + /> + Skip this book +
+ {expandedFiles === index && ( +
    + {book.files.map((file) => ( +
  • +
    + toggleFile(file)} + color="emerald" + ariaLabel={`Include ${file}`} + disabled={isSubmitting} + /> + + {file} + +
    +
  • + ))} +
+ )}
- {expandedFiles === index && ( -
    - {book.files.map((file) => ( -
  • {file}
  • - ))} -
- )} -
- ))} + ); + })}
{plan.ignored.length > 0 && ( @@ -181,7 +238,7 @@ export const PackReviewPanel = ({ )} + {/* Format picker toggle: choose which formats of a release to download */} + {!isCombinedMode && ( + + )} + {/* Manual query button */}