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 */}