Skip to content

Commit 2f96219

Browse files
committed
fix(critical): prevent data loss on cross-drive move with ignore list
When performing a MOVE across different drives with a non-empty ignore list, shutil.rmtree(source_dir) permanently deleted ignored subfolders that were never copied to the destination. Replaced blanket rmtree with ignore-aware deletion that preserves ignored subtrees and their ancestor directories in place. Also normalized sort method casing (frontend sends lowercase values such as 'location'/'people'; code expected title-case) to prevent silent fallthrough to the default sort path. Reported by user via email. Fixes v2.4.1.
1 parent 11513c9 commit 2f96219

5 files changed

Lines changed: 83 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- Added `/api/stats` endpoint to the FastAPI backend to supply summary metrics for internal diagnostics and MCP tool clients.
1414
- FastAPI backend now exports its bound network port to `APP_DATA_DIR/port.txt` so local tool clients can connect without hardcoded ports.
1515

16+
## [2.4.1] - 2026-07-12
17+
18+
### Fixed
19+
20+
- **Critical Data Loss**: Fixed cross-drive MOVE permanently deleting ignored subfolders. When a MOVE was performed across different drives with a non-empty ignore list, ignored folders were correctly skipped during the copy but then destroyed by a blanket `shutil.rmtree()` when the source was removed — bypassing the Recycle Bin with no undo. The deletion is now ignore-aware and only removes what was actually copied.
21+
- Fixed silent fallthrough bug where sort method names sent in lowercase by the frontend (e.g. `location`, `people`) did not match the expected title-case values, causing files to be sorted by the default method instead of the one selected.
22+
1623
## [2.3.0] - 2026-04-03
1724

1825
### Fixed

backend/organizer_logic.py

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,8 @@ def _get_hybrid_sort_paths(dest_dir, sort_options, exif_data, date_obj, names, m
745745
"""
746746
custom_filter = sort_options.get('custom_filter', {})
747747
filter_type = custom_filter.get('filter_type')
748+
if filter_type: # Normalize: 'people' → 'People', 'location' → 'Location', etc.
749+
filter_type = filter_type.title()
748750

749751
is_custom_match = False
750752
if filter_type == 'People':
@@ -793,7 +795,7 @@ def _get_hybrid_sort_paths(dest_dir, sort_options, exif_data, date_obj, names, m
793795
dest_paths.append(os.path.join(base_path, get_date_path(date_obj) if date_obj else UNKNOWN_DATE_FOLDER_NAME))
794796

795797
# Always add the base sort destination path
796-
base_sort_method = sort_options.get('base_sort', 'Date')
798+
base_sort_method = sort_options.get('base_sort', 'Date').title() # Normalize casing
797799
photo_location = get_location(exif_data)
798800
base_sort_paths = _get_standard_sort_paths(dest_dir, base_sort_method, date_obj, photo_location, names, multiple_countries_found, sort_options)
799801
dest_paths.extend(base_sort_paths)
@@ -848,7 +850,7 @@ def _core_processing_loop(work_dir, dest_dir, sort_options, update_callback, enc
848850
update_callback(100, "Scan complete. No supported image files found.", "complete")
849851
return 0
850852

851-
sort_method = sort_options.get('primary_sort', 'Date')
853+
sort_method = sort_options.get('primary_sort', 'Date').title() # Normalize: 'location' → 'Location'
852854
face_rec_mode = sort_options.get('face_mode', 'balanced')
853855
known_encodings, known_names = None, None
854856

@@ -1156,8 +1158,75 @@ def ignore_func(directory, contents):
11561158
if delete_original_source_on_success and operation_successful:
11571159
try:
11581160
update_callback(99, "Finalizing move: Removing original source directory...", "running", initial_analytics)
1159-
shutil.rmtree(source_dir)
1160-
logging.info(f"Successfully removed original source directory: {source_dir}")
1161+
1162+
# BUG FIX: A blanket shutil.rmtree(source_dir) would permanently destroy any
1163+
# ignored subfolders that were intentionally skipped during the copy step and
1164+
# therefore never transferred anywhere. Instead, we perform an ignore-aware
1165+
# deletion: remove only what was actually copied, and leave ignored subtrees
1166+
# (plus any ancestor directory that leads to one) intact.
1167+
if not ignore_set:
1168+
# No ignore list — safe to remove the whole tree as before.
1169+
shutil.rmtree(source_dir)
1170+
else:
1171+
# Determine every directory that is an ancestor of an ignored path so
1172+
# we can keep those directories alive even if they contain no other content.
1173+
ancestor_dirs = set()
1174+
for ignored_path in ignore_set:
1175+
# Walk from source_dir down to the ignored path's parent.
1176+
rel = os.path.relpath(ignored_path, source_dir)
1177+
parts = rel.split(os.sep)
1178+
for i in range(len(parts)):
1179+
ancestor_dirs.add(os.path.join(source_dir, *parts[:i]))
1180+
1181+
# Bottom-up walk so we can safely remove empty dirs as we go.
1182+
for dirpath, dirnames, filenames in os.walk(source_dir, topdown=False):
1183+
# Never touch an ignored subtree.
1184+
if dirpath in ignore_set:
1185+
continue
1186+
1187+
# Delete individual files that are not inside an ignored subtree.
1188+
for fname in filenames:
1189+
fpath = os.path.join(dirpath, fname)
1190+
# Check whether any prefix of fpath is an ignored dir.
1191+
in_ignored = any(
1192+
os.path.commonpath([fpath, ig]) == ig
1193+
for ig in ignore_set
1194+
)
1195+
if not in_ignored:
1196+
try:
1197+
os.remove(fpath)
1198+
except Exception as del_e:
1199+
logging.error(f"Could not delete source file '{fpath}': {del_e}")
1200+
1201+
# Remove subdirectories that are not ignored and not ancestors of an
1202+
# ignored path, provided they are now empty.
1203+
for dname in dirnames:
1204+
dpath = os.path.join(dirpath, dname)
1205+
if dpath in ignore_set:
1206+
continue # Preserve ignored subtree.
1207+
in_ignored = any(
1208+
os.path.commonpath([dpath, ig]) == ig
1209+
for ig in ignore_set
1210+
)
1211+
if in_ignored:
1212+
continue # Inside an ignored subtree — leave it.
1213+
if dpath not in ancestor_dirs and not os.listdir(dpath):
1214+
try:
1215+
os.rmdir(dpath)
1216+
except Exception as del_e:
1217+
logging.error(f"Could not remove source dir '{dpath}': {del_e}")
1218+
1219+
# Finally, remove the root source_dir itself only if it is now empty
1220+
# (it won't be if any ignored subtree lives inside it).
1221+
if not os.listdir(source_dir):
1222+
os.rmdir(source_dir)
1223+
else:
1224+
logging.info(
1225+
f"Original source directory kept because it still contains "
1226+
f"ignored sub-folders: {source_dir}"
1227+
)
1228+
1229+
logging.info(f"Successfully removed copied content from original source directory: {source_dir}")
11611230
except Exception as e:
11621231
logging.error(f"CRITICAL: Failed to remove original source directory after move: {e}")
11631232
update_callback(100, f"Error: Could not remove original source folder. Please remove it manually: {source_dir}", "warning", initial_analytics)

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "local-lens",
33
"private": true,
4-
"version": "2.4.0",
4+
"version": "2.4.1",
55
"type": "module",
66
"scripts": {
77
"dev:pre": "node ensure-backend.js",

frontend/src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "LocalLens"
3-
version = "2.4.0"
3+
version = "2.4.1"
44
description = "Application to organize photos using AI with face recognition and object detection."
55
authors = ["you"]
66
edition = "2021"

frontend/src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "Local Lens",
4-
"version": "2.4.0",
4+
"version": "2.4.1",
55
"identifier": "ashes.locallens",
66
"build": {
77
"beforeDevCommand": "pnpm run dev:pre && pnpm run dev",

0 commit comments

Comments
 (0)