diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index df253269..3eca6366 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -49,7 +49,10 @@ from ...utils.constants import ( VALID_LORA_TYPES, ) from ...utils.civitai_utils import rewrite_preview_url -from ...utils.example_images_paths import is_valid_example_images_root +from ...utils.example_images_paths import ( + find_non_compliant_items_in_example_images_root, + is_valid_example_images_root, +) from ...utils.lora_metadata import extract_trained_words from ...utils.session_logging import get_standalone_session_log_snapshot from ...utils.usage_stats import UsageStats @@ -1498,6 +1501,16 @@ class SettingsHandler: if not os.path.isdir(folder_path): return "Please set a dedicated folder for example images." if not self._is_dedicated_example_images_folder(folder_path): + offending = find_non_compliant_items_in_example_images_root(folder_path) + if offending: + items_str = ", ".join(repr(item) for item in offending[:5]) + if len(offending) > 5: + items_str += f" … and {len(offending) - 5} more" + return ( + f"The folder contains items that are not valid example image " + f"folders: {items_str}. Please use a dedicated, empty folder " + f"for example images to prevent accidental data loss." + ) return "Please set a dedicated folder for example images." return None diff --git a/py/utils/example_images_paths.py b/py/utils/example_images_paths.py index 3d4dbaa2..1c559ba2 100644 --- a/py/utils/example_images_paths.py +++ b/py/utils/example_images_paths.py @@ -12,6 +12,18 @@ from ..services.settings_manager import get_settings_manager _HEX_PATTERN = re.compile(r"[a-fA-F0-9]{64}") +# Filesystem/metadata files that are never created by the example images system +# and are safe to ignore during validation. The cleanup service only operates on +# directories, so these files pose no data-loss risk. +_SAFE_FILENAMES: frozenset[str] = frozenset({ + ".DS_Store", # macOS folder metadata + "Thumbs.db", # Windows thumbnail cache + "desktop.ini", # Windows folder customization + ".localized", # macOS folder name localization + ".gitkeep", # Placeholder to keep empty dirs in git + ".gitignore", # Git ignore rules +}) + logger = logging.getLogger(__name__) @@ -180,6 +192,22 @@ def is_hash_folder(name: str) -> bool: return bool(_HEX_PATTERN.fullmatch(name or "")) +def _is_safe_ignorable_entry(item: str, item_path: str) -> bool: + """Return True if *item* is a harmless system/hidden file we can skip. + + These files are never created by the example images system and are safe to + ignore because the cleanup/delete operations only act on **directories**, + never on individual files (other than ``.download_progress.json``). + """ + if item in _SAFE_FILENAMES: + return True + # Hide Unix hidden files (dotfiles) that are regular files, + # since the cleanup system never deletes or moves files. + if item.startswith(".") and os.path.isfile(item_path): + return True + return False + + def is_valid_example_images_root(folder_path: str) -> bool: """Check whether a folder looks like a dedicated example images root.""" @@ -190,9 +218,16 @@ def is_valid_example_images_root(folder_path: str) -> bool: for item in items: item_path = os.path.join(folder_path, item) + + # .download_progress.json is an expected metadata file — check before + # the generic dotfile rule so it stays explicitly documented. if item == ".download_progress.json" and os.path.isfile(item_path): continue + # Skip harmless system/hidden files — cleanup only touches directories + if _is_safe_ignorable_entry(item, item_path): + continue + if os.path.isdir(item_path): if is_hash_folder(item): continue @@ -211,6 +246,41 @@ def is_valid_example_images_root(folder_path: str) -> bool: return True +def find_non_compliant_items_in_example_images_root(folder_path: str) -> list[str]: + """Return the names of items that prevent *folder_path* from being a valid + example images root, or an empty list if the folder is valid. + + This mirrors ``is_valid_example_images_root`` but **returns** the offending + names instead of a boolean, so callers can produce actionable error messages. + """ + try: + items = os.listdir(folder_path) + except OSError as exc: + return [f""] + + offending: list[str] = [] + + for item in items: + item_path = os.path.join(folder_path, item) + + # Same skip rules as is_valid_example_images_root + if item == ".download_progress.json" and os.path.isfile(item_path): + continue + if _is_safe_ignorable_entry(item, item_path): + continue + if os.path.isdir(item_path): + if is_hash_folder(item): + continue + if item == "_deleted": + continue + if _library_folder_has_only_hash_dirs(item_path): + continue + + offending.append(item) + + return offending + + def _library_folder_has_only_hash_dirs(path: str) -> bool: """Return True when a library subfolder only contains hash folders or metadata files.""" diff --git a/tests/utils/test_example_images_paths.py b/tests/utils/test_example_images_paths.py index 76839198..0ec45fa5 100644 --- a/tests/utils/test_example_images_paths.py +++ b/tests/utils/test_example_images_paths.py @@ -9,6 +9,7 @@ import pytest from py.services.settings_manager import get_settings_manager from py.utils.example_images_paths import ( ensure_library_root_exists, + find_non_compliant_items_in_example_images_root, get_model_folder, get_model_relative_path, is_valid_example_images_root, @@ -140,3 +141,68 @@ def test_is_valid_example_images_root_accepts_legacy_library_structure(tmp_path, (hash_folder / 'image.png').write_text('data', encoding='utf-8') assert is_valid_example_images_root(str(tmp_path)) is True + + +def test_find_non_compliant_items_returns_empty_for_valid_root(tmp_path, settings_manager): + """An empty folder or one with only hash dirs should return [].""" + settings_manager.settings['example_images_path'] = str(tmp_path) + + # Empty folder + assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == [] + + # Only hash folders + hash_folder = tmp_path / ('f' * 64) + hash_folder.mkdir() + (hash_folder / 'image.png').write_text('data', encoding='utf-8') + assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == [] + + +def test_find_non_compliant_items_returns_offending_names(tmp_path, settings_manager): + """A folder with non-hash items should return their names.""" + settings_manager.settings['example_images_path'] = str(tmp_path) + + # Create a valid hash folder so the root is otherwise acceptable + hash_folder = tmp_path / ('a' * 64) + hash_folder.mkdir() + + # Add an offending file + (tmp_path / 'readme.txt').write_text('hello', encoding='utf-8') + assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == ['readme.txt'] + + # Add an offending directory with content (empty dirs are accepted as + # potential legacy library folders by _library_folder_has_only_hash_dirs) + offending_dir = tmp_path / 'not_a_hash' + offending_dir.mkdir() + (offending_dir / 'some_file.txt').write_text('data', encoding='utf-8') + items = find_non_compliant_items_in_example_images_root(str(tmp_path)) + assert 'readme.txt' in items + assert 'not_a_hash' in items + + +def test_find_non_compliant_items_ignores_hidden_files(tmp_path, settings_manager): + """Hidden/system files should not appear in offending list.""" + settings_manager.settings['example_images_path'] = str(tmp_path) + + # .DS_Store is an allowed file + (tmp_path / '.DS_Store').write_text('', encoding='utf-8') + assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == [] + + # Thumbs.db too + (tmp_path / 'Thumbs.db').write_text('', encoding='utf-8') + assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == [] + + +def test_find_non_compliant_items_accepts_download_progress_json(tmp_path, settings_manager): + """.download_progress.json should be recognised as a valid metadata file.""" + settings_manager.settings['example_images_path'] = str(tmp_path) + + (tmp_path / '.download_progress.json').write_text('{}', encoding='utf-8') + assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == [] + + +def test_find_non_compliant_items_reports_directory_error(tmp_path): + """When the directory cannot be listed, return an explanatory message.""" + non_existent = tmp_path / 'does-not-exist' + result = find_non_compliant_items_in_example_images_root(str(non_existent)) + assert len(result) == 1 + assert 'cannot list directory' in result[0]