diff --git a/ChangeLog.md b/ChangeLog.md index 3ed2274..aed56f1 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -13,6 +13,10 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document - Improved TurboJPEG setup on Windows by using shared library detection logic in JPEG decoding and thumbnail prefetching. Thanks to Andy Arijs for the PR! - Added Windows documentation for installing turbojpeg.dll, using FASTSTACK_TURBOJPEG_LIB, and understanding fallback behavior. Thanks to Andy Arijs! - FastStack now more clearly explains when it falls back to Pillow for JPEG decoding and thumbnails. Thanks to Andy Arijs! +- Recycle bin restore is now per-directory: each bin shows its destination, file counts, and an independent Restore button +- Bins with legacy files that cannot be auto-restored are clearly labeled instead of silently ignored +- Restore feedback reports skipped files and legacy remainders +- RAW decode failures now show a distinct "Preview unavailable" placeholder instead of a plain dark image ## 1.6.0 (2026-03-06) diff --git a/faststack/app.py b/faststack/app.py index 1f77d70..0be6dd4 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -3740,7 +3740,11 @@ def get_batch_count_for_current_image(self) -> int: return 0 @staticmethod - def _move_to_recycle(src: Path, _created_bins: set | None = None) -> Optional[Path]: + def _move_to_recycle( + src: Path, + _created_bins: set | None = None, + unique_tag: str | None = None, + ) -> Optional[Path]: """Moves a file to the recycle bin safely. Thread-safe, no Qt access. Uses uuid-based destination names to avoid collision checks. @@ -3749,6 +3753,9 @@ def _move_to_recycle(src: Path, _created_bins: set | None = None) -> Optional[Pa Args: src: Source file path. _created_bins: Optional set of already-created recycle bin dirs (cache). + unique_tag: Optional shared UUID tag. When recycling paired files + (JPG + RAW), pass the same tag so stems still match in the + recycle bin and find_images() can re-pair them. Returns: Destination path in recycle bin, or None on failure. @@ -3768,9 +3775,12 @@ def _move_to_recycle(src: Path, _created_bins: set | None = None) -> Optional[Pa log.error("Failed to create recycle bin: %s", e) return None - # Use uuid suffix to guarantee unique name without existence checks - unique_tag = uuid.uuid4().hex[:8] - dest = recycle_bin / f"{src.stem}.{unique_tag}{src.suffix}" + # Use uuid suffix to guarantee unique name without existence checks. + # Paired files (JPG + RAW) share the same tag so their stems match + # in the recycle bin and find_images() can re-pair them. + if unique_tag is None: + unique_tag = uuid.uuid4().hex[:8] + dest = recycle_bin / f"{src.stem}._fs_{unique_tag}{src.suffix}" try: # Fast path: rename within same filesystem (no data copy) @@ -3890,7 +3900,12 @@ def _delete_worker( actual_raw_exists = bool(raw_path and raw_path.exists()) try: - recycled_jpg = AppController._move_to_recycle(jpg_path, created_bins) + # Share the same UUID tag for JPG + RAW so their stems + # still match in the recycle bin (enables re-pairing). + shared_tag = uuid.uuid4().hex[:8] + recycled_jpg = AppController._move_to_recycle( + jpg_path, created_bins, unique_tag=shared_tag + ) if not recycled_jpg: failures.append( { @@ -3905,7 +3920,7 @@ def _delete_worker( if actual_raw_exists: try: recycled_raw = AppController._move_to_recycle( - raw_path, created_bins + raw_path, created_bins, unique_tag=shared_tag ) if not recycled_raw: raise OSError("RAW move failed") @@ -4555,8 +4570,9 @@ def _shift(orig_idx: int) -> int: # Use new targeted eviction with tombstones self.image_cache.evict_paths(paths_to_evict) - # Cancel any pending prefetch tasks (crucial to stop re-caching deleted items) + # Sync prefetcher's image list and cancel pending tasks if self.prefetcher: + self.prefetcher.set_image_files(self.image_files) self.prefetcher.cancel_all() # Update ID mapping (now fast due to string hashing) @@ -4915,6 +4931,7 @@ def undo_delete(self): if img.raw_pair: paths_to_evict.append(img.raw_pair) self.image_cache.evict_paths(paths_to_evict) + self.prefetcher.set_image_files(self.image_files) self.prefetcher.cancel_all() if self.image_files: self.prefetcher.update_prefetch(self.current_index) @@ -5321,42 +5338,6 @@ def _on_cache_evict(self, key, value, info): QTimer.singleShot(0, lambda: self.update_status_message(msg)) log.warning(msg) - def restore_all_from_recycle_bin(self): - """Restores all files from tracked recycle bins to their parent folders.""" - restored_count = 0 - - bins_to_restore = set(self.active_recycle_bins) - try: - bins_to_restore.add(self.image_dir / "image recycle bin") - except Exception: - pass - - for bin_path in bins_to_restore: - if not bin_path.exists(): - continue - - restore_target = bin_path.parent - try: - for file_in_bin in bin_path.iterdir(): - dest_path = restore_target / file_in_bin.name - if dest_path.exists(): - log.warning("File already exists, skipping: %s", dest_path) - continue - - try: - shutil.move(str(file_in_bin), str(dest_path)) - restored_count += 1 - log.info("Restored %s from %s", file_in_bin.name, bin_path.name) - except OSError as e: - log.error("Failed to restore %s: %s", file_in_bin.name, e) - except OSError: - log.exception("Failed to iterate recycle bin %s", bin_path) - - # Clear delete history since we restored everything - self.delete_history.clear() - - log.info("Restored %d files from recycle bins", restored_count) - @Slot() def edit_in_photoshop(self): if not self.image_files: @@ -7387,15 +7368,10 @@ def get_recycle_bin_stats(self) -> List[Dict[str, Any]]: }, ...] """ stats = [] - # Filter out bins that don't exist anymore - active_bins = {p for p in self.active_recycle_bins if p.exists() and p.is_dir()} - # Always check the local directory's recycle bin for items from previous sessions - local_bin = self.image_dir / "image recycle bin" - if local_bin.exists() and local_bin.is_dir(): - active_bins.add(local_bin) + active_bins = self._collect_active_bins() self.active_recycle_bins = active_bins - for bin_path in self.active_recycle_bins: + for bin_path in active_bins: try: jpg_count = 0 raw_count = 0 @@ -7445,6 +7421,235 @@ def cleanup_recycle_bins(self): # Clear stats cache since we deleted files/folders clear_raw_count_cache() + # ---- regex for reversing UUID-suffixed recycle bin names ---- + # Current format uses ``._fs_`` marker: ``{stem}._fs_{8hex}{suffix}`` + _RECYCLE_FS_RE = re.compile(r"^(.+)\._fs_[0-9a-f]{8}$", re.IGNORECASE) + + @staticmethod + def _original_name_from_recycled(recycled_path: Path) -> Optional[str]: + """Derive the original filename from a recycled file's UUID-suffixed name. + + Current format: ``{original_stem}._fs_{8-hex-uuid}{original_suffix}`` + e.g. ``IMG_001._fs_a7c3f2e1.jpg`` → ``IMG_001.jpg`` + + Only matches the ``._fs_`` marker to avoid false positives on + legitimate filenames that happen to end with a dot-8hex segment + (e.g. ``photo.a1b2c3d4.jpg``). + + Returns: + The original filename, or None if the name doesn't match the pattern. + """ + m = AppController._RECYCLE_FS_RE.match(recycled_path.stem) + if m: + return m.group(1) + recycled_path.suffix + return None + + def _collect_active_bins(self) -> set: + """Return the set of existing recycle bin directories (tracked + local). + + Excludes any bin that still has outstanding pending-delete jobs so that + the restore UI cannot act on a bin the worker is still writing to. + """ + # Build set of bin dirs that have in-flight delete jobs. + pending_bins: set = set() + for job in self._pending_delete_jobs.values(): + for img in job.images_to_delete: + pending_bins.add(img.path.parent / "image recycle bin") + + active = { + p + for p in self.active_recycle_bins + if p.exists() and p.is_dir() and p not in pending_bins + } + local_bin = self.image_dir / "image recycle bin" + if ( + local_bin.exists() + and local_bin.is_dir() + and local_bin not in pending_bins + ): + active.add(local_bin) + return active + + def get_per_bin_restore_info(self) -> List[Dict[str, Any]]: + """Compute per-bin restore information with classification. + + Returns a list of dicts, one per non-empty bin, sorted deterministically + (restorable first, then unavailable; within each bucket by dest_dir). + + Each dict contains: + bin_id, bin_path, dest_dir, label, status, + jpg_count, raw_count, other_count, total_restorable, + total_files, legacy_count + """ + results = [] + + for bin_path in self._collect_active_bins(): + dest_dir = bin_path.parent + jpg_count = 0 + raw_count = 0 + other_count = 0 + legacy_count = 0 + total_files = 0 + + try: + for p in bin_path.iterdir(): + if not p.is_file(): + continue + total_files += 1 + original_name = self._original_name_from_recycled(p) + if original_name is None: + legacy_count += 1 + continue + ext = p.suffix.lower() + if ext in (".jpg", ".jpeg", ".jpe"): + jpg_count += 1 + elif ext in RAW_EXTENSIONS: + raw_count += 1 + else: + other_count += 1 + except OSError: + continue + + if total_files == 0: + continue # skip truly empty bins + + total_restorable = jpg_count + raw_count + other_count + dest_dir_str = str(dest_dir) + + results.append( + { + "bin_id": str(bin_path), + "bin_path": str(bin_path), + "dest_dir": dest_dir_str, + "label": dest_dir.name or dest_dir_str, + "status": "restorable" if total_restorable > 0 else "unavailable", + "jpg_count": jpg_count, + "raw_count": raw_count, + "other_count": other_count, + "total_restorable": total_restorable, + "total_files": total_files, + "legacy_count": legacy_count, + } + ) + + # Deterministic sort: restorable first, then by dest_dir within bucket + status_order = {"restorable": 0, "unavailable": 1} + results.sort(key=lambda r: (status_order.get(r["status"], 9), r["dest_dir"])) + return results + + def restore_single_bin(self, bin_path_str: str) -> Dict[str, Any]: + """Restore all UUID-suffixed files from a single recycle bin. + + Only allows restoring from paths that are currently in the tracked + active recycle bin set. Refuses arbitrary paths. + + Args: + bin_path_str: Absolute path string of the recycle bin directory. + + Returns: + Dict with restored_count, skipped_count, legacy_remaining_count, + dest_dir, bin_path. + """ + bin_path = Path(bin_path_str).resolve() + dest_dir = bin_path.parent + dest_dir_str = str(dest_dir) + result = { + "restored_count": 0, + "skipped_count": 0, + "legacy_remaining_count": 0, + "dest_dir": dest_dir_str, + "bin_path": bin_path_str, + } + + # Verify the requested path is a known active recycle bin. + active_bins = {p.resolve() for p in self._collect_active_bins()} + if bin_path not in active_bins: + log.warning( + "restore_single_bin: refusing path not in active bins: %s", + bin_path_str, + ) + return result + + if not bin_path.exists() or not bin_path.is_dir(): + log.warning("restore_single_bin: bin does not exist: %s", bin_path_str) + return result + + restored_paths: Set[Path] = set() # recycled paths successfully moved + + try: + # Snapshot the listing so moves during iteration can't skip entries. + entries = list(bin_path.iterdir()) + except OSError: + log.exception("Failed to iterate recycle bin %s", bin_path) + entries = [] + + for p in entries: + if not p.is_file(): + continue + original_name = self._original_name_from_recycled(p) + if original_name is None: + result["legacy_remaining_count"] += 1 + continue + dest = dest_dir / original_name + if dest.exists(): + log.warning( + "Skipping restore of %s — %s already exists", + p.name, + dest.name, + ) + result["skipped_count"] += 1 + continue + try: + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(p), str(dest)) + log.info("Restored %s → %s", p.name, dest.name) + result["restored_count"] += 1 + restored_paths.add(p) + except OSError as e: + log.error("Failed to restore %s: %s", p.name, e) + result["skipped_count"] += 1 + + # Prune delete_history and undo_history entries whose recycled + # paths were just restored, so Ctrl+Z doesn't try to restore + # files that are already back in place. + if restored_paths: + # Resolve all restored paths so comparison works even when + # delete_history contains relative paths (e.g. app started + # with a relative image directory). + resolved_restored = {p.resolve() for p in restored_paths} + + def _record_stale(record: DeleteRecord) -> bool: + """True if any recycled path in this record was restored.""" + (_, jpg_bin), (_, raw_bin) = record + return ( + (jpg_bin is not None and jpg_bin.resolve() in resolved_restored) + or (raw_bin is not None and raw_bin.resolve() in resolved_restored) + ) + + self.delete_history = [ + r for r in self.delete_history if not _record_stale(r) + ] + self.undo_history = [ + (atype, adata, ts) + for atype, adata, ts in self.undo_history + if atype != "delete" or not _record_stale(adata) + ] + + # Clean up empty bin directory + try: + remaining = list(bin_path.iterdir()) + if not remaining: + bin_path.rmdir() + log.info("Removed empty recycle bin: %s", bin_path) + except OSError: + pass + + self.active_recycle_bins = { + p for p in self.active_recycle_bins if p.exists() and p.is_dir() + } + clear_raw_count_cache() + return result + def main( image_dir: Optional[str] = None, diff --git a/faststack/imaging/prefetch.py b/faststack/imaging/prefetch.py index db3bb8f..583d1e3 100644 --- a/faststack/imaging/prefetch.py +++ b/faststack/imaging/prefetch.py @@ -31,6 +31,78 @@ log = logging.getLogger(__name__) +# RAW extensions that Pillow typically cannot decode (no embedded JPEG preview). +# When decode fails for these, we generate a placeholder instead of returning None. +_RAW_EXTENSIONS = frozenset( + {".orf", ".rw2", ".cr2", ".cr3", ".arw", ".nef", ".raf", ".dng"} +) + + +def _make_raw_placeholder(width: int, height: int) -> np.ndarray: + """Generate a themed 'Preview unavailable' placeholder for undecodable RAW files. + + Draws a circle-with-slash icon and centered text so the placeholder is + visually distinct from actual image content. Theme-aware via config. + """ + if width <= 0 or height <= 0: + width, height = 256, 256 + + # Theme-aware palette + theme = config.get("core", "theme", fallback="dark") + if theme == "dark": + bg_color = (30, 30, 30) + text_color = (120, 120, 120) + icon_color = (80, 80, 80) + else: + bg_color = (240, 240, 240) + text_color = (140, 140, 140) + icon_color = (180, 180, 180) + + from PIL import ImageDraw, ImageFont + + img = PILImage.new("RGB", (width, height), bg_color) + draw = ImageDraw.Draw(img) + + cx, cy = width // 2, height // 2 + short = min(width, height) + icon_r = max(10, short // 8) + + # Circle-with-slash "no preview" icon + if icon_r >= 10: + stroke = max(2, icon_r // 10) + icon_cy = cy - icon_r # icon above center + draw.ellipse( + [cx - icon_r, icon_cy - icon_r, cx + icon_r, icon_cy + icon_r], + outline=icon_color, + width=stroke, + ) + draw.line( + [cx - icon_r, icon_cy + icon_r, cx + icon_r, icon_cy - icon_r], + fill=icon_color, + width=stroke, + ) + + # "Preview unavailable" text below icon + text = "Preview unavailable" + font_size = max(12, short // 20) + try: + font = ImageFont.load_default(size=font_size) + except TypeError: + # Older Pillow without size= parameter + font = ImageFont.load_default() + + bbox = draw.textbbox((0, 0), text, font=font) + tw = bbox[2] - bbox[0] + text_y = cy + (icon_r // 2 if icon_r >= 10 else 0) + draw.text( + ((width - tw) // 2, text_y), + text, + fill=text_color, + font=font, + ) + + return np.array(img) + # ---- Option C: ICC Color Management Setup ---- SRGB_PROFILE = ImageCms.createProfile("sRGB") @@ -539,7 +611,12 @@ def _decode_and_cache( target_path, e, ) - return None + if target_path.suffix.lower() in _RAW_EXTENSIONS: + buffer = _make_raw_placeholder( + display_width, display_height + ) + else: + return None img = PILImage.fromarray(buffer) @@ -636,7 +713,12 @@ def _decode_and_cache( log.warning( "Decode failed index=%d path=%s: %s", index, target_path, e ) - return None + if target_path.suffix.lower() in _RAW_EXTENSIONS: + buffer = _make_raw_placeholder( + display_width, display_height + ) + else: + return None if buffer is None: return None diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml index cd52321..620425e 100644 --- a/faststack/qml/Main.qml +++ b/faststack/qml/Main.qml @@ -1813,7 +1813,19 @@ ApplicationWindow { width: Math.min(600, parent.width * 0.9) modal: true standardButtons: Dialog.NoButton - + + // Single source of truth for per-bin restore info. + // Populated on open and after each restore action. + property var binInfo: [] + + function refreshBinInfo() { + if (uiState) { + binInfo = uiState.getPerBinRestoreInfo() + } + } + + onOpened: refreshBinInfo() + // Ensure the dialog is fully opaque and has a solid background background: Rectangle { color: root.isDarkTheme ? "#1e1e1e" : "#fdfdfd" @@ -1821,7 +1833,7 @@ ApplicationWindow { border.width: 1 radius: 12 } - + header: Rectangle { implicitHeight: 60 color: root.isDarkTheme ? "#252525" : "#f2f2f2" @@ -1845,30 +1857,163 @@ ApplicationWindow { contentItem: Column { id: dialogContent width: recycleBinCleanupDialog.width - spacing: 20 + spacing: 16 topPadding: 10 bottomPadding: 10 leftPadding: 20 rightPadding: 20 - + + // Summary line Label { width: dialogContent.width - 40 text: uiState ? uiState.recycleBinStatsText : "Loading..." color: root.isDarkTheme ? "#efefef" : "#333333" wrapMode: Text.WordWrap - font.pixelSize: 16 + font.pixelSize: 15 lineHeight: 1.3 } + // ---- Per-bin restore rows (restorable bins only) ---- + Repeater { + id: restorableRepeater + model: recycleBinCleanupDialog.binInfo.filter(function(b) { return b.status === "restorable" }) + + delegate: Rectangle { + width: dialogContent.width - 40 + height: binRowLayout.implicitHeight + 20 + radius: 8 + color: root.isDarkTheme ? "#252525" : "#f2f2f2" + border.color: root.isDarkTheme ? "#333333" : "#e0e0e0" + border.width: 1 + + RowLayout { + id: binRowLayout + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 12 + spacing: 12 + + Column { + Layout.fillWidth: true + spacing: 2 + + Label { + text: modelData.label + color: root.isDarkTheme ? "#efefef" : "#333333" + font.pixelSize: 14 + font.bold: true + elide: Text.ElideMiddle + width: parent.width + } + Label { + text: modelData.dest_dir + color: root.isDarkTheme ? "#888888" : "#999999" + font.pixelSize: 11 + elide: Text.ElideMiddle + width: parent.width + } + Label { + text: { + var parts = [] + if (modelData.jpg_count > 0) parts.push(modelData.jpg_count + " JPG") + if (modelData.raw_count > 0) parts.push(modelData.raw_count + " RAW") + if (modelData.other_count > 0) parts.push(modelData.other_count + " other") + var s = parts.join(", ") + if (modelData.legacy_count > 0) + s += " + " + modelData.legacy_count + " legacy" + return s + " \u2014 " + modelData.total_restorable + " restorable" + } + color: root.isDarkTheme ? "#aaaaaa" : "#666666" + font.pixelSize: 13 + } + } + + // Per-bin Restore button + Rectangle { + width: restoreBinBtnText.implicitWidth + 30 + height: 34 + radius: 17 + color: "#4fb360" + Layout.alignment: Qt.AlignVCenter + + Text { + id: restoreBinBtnText + anchors.centerIn: parent + text: "Restore" + color: "white" + font.pixelSize: 13 + font.bold: true + } + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (uiState) { + uiState.restoreSingleBin(modelData.bin_path) + recycleBinCleanupDialog.refreshBinInfo() + // Auto-close if nothing left + if (recycleBinCleanupDialog.binInfo.length === 0) { + recycleBinCleanupDialog.close() + } + } + } + onEntered: parent.color = "#5cc46d" + onExited: parent.color = "#4fb360" + } + } + } + } + } + + // ---- Unavailable bins section ---- + Column { + width: dialogContent.width - 40 + spacing: 6 + visible: { + var items = recycleBinCleanupDialog.binInfo.filter(function(b) { return b.status === "unavailable" }) + return items.length > 0 + } + + Label { + text: "Not auto-restorable (legacy format)" + color: root.isDarkTheme ? "#ff8a65" : "#bf360c" + font.pixelSize: 14 + font.bold: true + } + Label { + width: parent.width + text: "These bins contain files from an older version without restore metadata. They can only be deleted." + color: root.isDarkTheme ? "#999999" : "#777777" + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + + Repeater { + model: recycleBinCleanupDialog.binInfo.filter(function(b) { return b.status === "unavailable" }) + + delegate: Label { + width: dialogContent.width - 40 + text: modelData.dest_dir + " \u2014 " + modelData.total_files + " file" + (modelData.total_files !== 1 ? "s" : "") + color: root.isDarkTheme ? "#aaaaaa" : "#666666" + font.pixelSize: 13 + elide: Text.ElideMiddle + topPadding: 2 + } + } + } + + // ---- Expandable details section ---- property bool detailsExpanded: false Row { width: dialogContent.width - 40 spacing: 12 - + Label { - text: "Files to be removed:" - color: "#81C784" // Soft green + text: "All files in recycle bins:" + color: "#81C784" font.pixelSize: 15 font.bold: true anchors.verticalCenter: parent.verticalCenter @@ -1911,15 +2056,14 @@ ApplicationWindow { border.width: 1 radius: 8 clip: true - + Behavior on height { NumberAnimation { duration: 250; easing.type: Easing.OutCubic } } - + ScrollView { id: detailsScrollView anchors.fill: parent anchors.margins: 8 - TextArea { id: detailsText width: detailsScrollView.availableWidth @@ -1937,13 +2081,13 @@ ApplicationWindow { } } } - - // Premium Pill Buttons + + // ---- Action buttons ---- Row { anchors.horizontalCenter: parent.horizontalCenter spacing: 15 topPadding: 10 - + // Cancel Button Rectangle { width: cancelBtnText.implicitWidth + 40 @@ -1952,7 +2096,7 @@ ApplicationWindow { color: "transparent" border.color: root.isDarkTheme ? "#555555" : "#cccccc" border.width: 1 - + Text { id: cancelBtnText anchors.centerIn: parent @@ -1977,7 +2121,7 @@ ApplicationWindow { height: 44 radius: 22 color: root.isDarkTheme ? "#333333" : "#e0e0e0" - + Text { id: keepBtnText anchors.centerIn: parent @@ -2005,8 +2149,8 @@ ApplicationWindow { width: deleteBtnText.implicitWidth + 40 height: 44 radius: 22 - color: "#ef5350" // Premium Red - + color: "#ef5350" + Text { id: deleteBtnText anchors.centerIn: parent diff --git a/faststack/ui/provider.py b/faststack/ui/provider.py index b5e6ffc..d0c93bb 100644 --- a/faststack/ui/provider.py +++ b/faststack/ui/provider.py @@ -1570,25 +1570,26 @@ def gridPrefetchRange(self, startIndex: int, endIndex: int, maxCount: int = 800) @Property(str, notify=recycleBinStatsTextChanged) def recycleBinStatsText(self): - """Returns a formatted string of recycle bin stats summary.""" - stats = self.app_controller.get_recycle_bin_stats() - if not stats: + """Returns a formatted summary of recycle bin contents.""" + info = self.app_controller.get_per_bin_restore_info() + if not info: return "" - summary = "The following recycle bins contain items:\n" - for item in stats: - counts = [] - if item.get("jpg_count", 0) > 0: - counts.append(f"{item['jpg_count']} JPG") - if item.get("raw_count", 0) > 0: - counts.append(f"{item['raw_count']} RAW") - if item.get("other_count", 0) > 0: - counts.append(f"{item['other_count']} other") - - count_str = f" ({', '.join(counts)})" if counts else "" - summary += f"\n• {item['path']}:\n {item['count']} files{count_str}\n" - - summary += "\nDo you want to permanently delete them before quitting?" + total_files = sum(b["total_files"] for b in info) + n_bins = len(info) + unavailable = [b for b in info if b["status"] == "unavailable"] + + summary = ( + f"{total_files} file{'s' if total_files != 1 else ''} " + f"in {n_bins} recycle bin{'s' if n_bins != 1 else ''}." + ) + if unavailable: + n_un = len(unavailable) + summary += ( + f"\n{n_un} bin{'s' if n_un != 1 else ''} " + f"contain{'s' if n_un == 1 else ''} only legacy files " + f"and cannot be restored automatically." + ) return summary @Property(str, notify=recycleBinDetailedTextChanged) @@ -1628,3 +1629,50 @@ def cleanupRecycleBins(self): """Deletes all tracked recycle bins.""" self.app_controller.cleanup_recycle_bins() self.refreshRecycleBinStats() + + @Slot(result="QVariantList") + def getPerBinRestoreInfo(self): + """Returns per-bin restore info as a list of JS-compatible dicts. + + Each entry has: bin_id, bin_path, dest_dir, label, status, + jpg_count, raw_count, other_count, total_restorable, + total_files, legacy_count. + """ + return self.app_controller.get_per_bin_restore_info() + + @Slot(str, result=str) + def restoreSingleBin(self, bin_path: str) -> str: + """Restore files from a single recycle bin. + + Returns a user-facing status message string. + """ + result = self.app_controller.restore_single_bin(bin_path) + self.refreshRecycleBinStats() + + restored = result["restored_count"] + skipped = result["skipped_count"] + legacy = result["legacy_remaining_count"] + dest = result["dest_dir"] + + # Build context-aware feedback message + parts = [] + if restored > 0: + parts.append( + f"Restored {restored} file{'s' if restored != 1 else ''} to {dest}" + ) + if skipped > 0: + parts.append( + f"{skipped} skipped (already exist{'s' if skipped == 1 else ''})" + ) + + msg = ", ".join(parts) if parts else "Nothing to restore" + + if legacy > 0: + msg += ( + f"; {legacy} legacy file{'s' if legacy != 1 else ''} " + f"remain{'s' if legacy == 1 else ''} in recycle bin" + ) + + log.info("Restore result: %s", msg) + self.app_controller.update_status_message(msg, timeout=5000) + return msg