From bf1134e47f5d218fb220f5fccf0f7a99a00b770f Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 28 Jan 2026 15:42:14 +0100 Subject: [PATCH] claude --- CLAUDE.md | 61 +++++++ __pycache__/engine.cpython-312.pyc | Bin 0 -> 43105 bytes __pycache__/gallery_app.cpython-312.pyc | Bin 0 -> 92184 bytes engine.py | 123 +++++++++------ gallery_app.py | 201 ++++++++++++++++-------- 5 files changed, 269 insertions(+), 116 deletions(-) create mode 100644 CLAUDE.md create mode 100644 __pycache__/engine.cpython-312.pyc create mode 100644 __pycache__/gallery_app.cpython-312.pyc diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..067db60 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,61 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Turbo Sorter Pro v12.5 - A dual-interface image organization tool combining Streamlit (admin dashboard) and NiceGUI (gallery interface) for managing large image collections through time-sync matching, ID collision resolution, category-based sorting, and gallery tagging with pairing capabilities. + +## Commands + +```bash +# Install dependencies +pip install -r requirements.txt + +# Run Streamlit dashboard (port 8501) +streamlit run app.py --server.port=8501 --server.address=0.0.0.0 + +# Run NiceGUI gallery (port 8080) +python3 gallery_app.py + +# Both services (container startup) +./start.sh + +# Syntax check all Python files +python3 -m py_compile *.py +``` + +## Architecture + +### Dual-Framework Design +- **Streamlit (app.py, port 8501)**: Administrative dashboard with 5 modular tabs for management workflows +- **NiceGUI (gallery_app.py, port 8080)**: Modern gallery interface for image tagging and pairing operations +- **Shared Backend**: Both UIs use `SorterEngine` (engine.py) and the same SQLite database + +### Core Components + +| File | Purpose | +|------|---------| +| `engine.py` | Static `SorterEngine` class - all DB operations, file handling, image compression | +| `gallery_app.py` | NiceGUI gallery with `AppState` class for centralized state management | +| `app.py` | Streamlit entry point, loads tab modules | +| `tab_*.py` | Independent tab modules for each workflow | + +### Database +SQLite at `/app/sorter_database.db` with tables: profiles, folder_ids, categories, staging_area, processed_log, folder_tags, profile_categories, pairing_settings. + +### Tab Workflows +1. **Time-Sync Discovery** - Match images by timestamp across folders +2. **ID Review** - Resolve ID collisions between target/control folders +3. **Unused Archive** - Manage rejected image pairs +4. **Category Sorter** - One-to-many categorization +5. **Gallery Staged** - Grid-based tagging with Gallery/Pairing dual modes + +## Key Patterns + +- **ID Format**: `id001_`, `id002_` (zero-padded 3-digit prefix) +- **Staging Pattern**: Two-phase commit (stage → commit) with undo support +- **Image Formats**: .jpg, .jpeg, .png, .webp, .bmp, .tiff +- **Compression**: WebP with ThreadPoolExecutor (8 workers) +- **Permissions**: chmod 0o777 applied to committed files +- **Default Paths**: `/storage` when not configured diff --git a/__pycache__/engine.cpython-312.pyc b/__pycache__/engine.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fc732415f29855c5d0f1ae9b1d4ab6770f88818b GIT binary patch literal 43105 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 F{y+8}(WL+Y literal 0 HcmV?d00001 diff --git a/__pycache__/gallery_app.cpython-312.pyc b/__pycache__/gallery_app.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ea1432887b28e267c619523d032046a17e40a588 GIT binary patch literal 92184 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 XL;RQH&+VVoPpfBk{X)?})+hf5$%x|f literal 0 HcmV?d00001 diff --git a/engine.py b/engine.py index 930ca00..c34910c 100644 --- a/engine.py +++ b/engine.py @@ -1,12 +1,28 @@ import os import shutil import sqlite3 +from contextlib import contextmanager from PIL import Image from io import BytesIO class SorterEngine: DB_PATH = "/app/sorter_database.db" + @staticmethod + @contextmanager + def get_db(): + """Context manager for database connections. + Ensures proper commit/rollback and always closes connection.""" + conn = sqlite3.connect(SorterEngine.DB_PATH) + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + # --- 1. DATABASE INITIALIZATION --- @staticmethod def init_db(): @@ -51,7 +67,15 @@ class SorterEngine: if cursor.fetchone()[0] == 0: for cat in ["_TRASH", "control", "Default", "Action", "Solo"]: cursor.execute("INSERT OR IGNORE INTO categories VALUES (?)", (cat,)) - + + # --- PERFORMANCE INDEXES --- + # Index for staging_area queries filtered by category + cursor.execute("CREATE INDEX IF NOT EXISTS idx_staging_category ON staging_area(target_category)") + # Index for folder_tags queries filtered by profile and folder_path + cursor.execute("CREATE INDEX IF NOT EXISTS idx_folder_tags_profile ON folder_tags(profile, folder_path)") + # Index for profile_categories lookups + cursor.execute("CREATE INDEX IF NOT EXISTS idx_profile_categories ON profile_categories(profile)") + conn.commit() conn.close() @@ -146,42 +170,48 @@ class SorterEngine: @staticmethod def load_profiles(): - """Loads all workspace presets including pairing settings.""" + """Loads all workspace presets including pairing settings. + Uses LEFT JOIN to fetch all data in a single query (fixes N+1 problem).""" conn = sqlite3.connect(SorterEngine.DB_PATH) cursor = conn.cursor() - cursor.execute("SELECT * FROM profiles") - rows = cursor.fetchall() - - # Ensure pairing_settings table exists - cursor.execute('''CREATE TABLE IF NOT EXISTS pairing_settings - (profile TEXT PRIMARY KEY, - adjacent_folder TEXT, - main_category TEXT, - adj_category TEXT, - main_output TEXT, - adj_output TEXT, + + # Ensure pairing_settings table exists before JOIN + cursor.execute('''CREATE TABLE IF NOT EXISTS pairing_settings + (profile TEXT PRIMARY KEY, + adjacent_folder TEXT, + main_category TEXT, + adj_category TEXT, + main_output TEXT, + adj_output TEXT, time_window INTEGER)''') - + + # Single query with LEFT JOIN - eliminates N+1 queries + cursor.execute(''' + SELECT p.name, p.tab1_target, p.tab2_target, p.tab2_control, + p.tab4_source, p.tab4_out, p.mode, p.tab5_source, p.tab5_out, + ps.adjacent_folder, ps.main_category, ps.adj_category, + ps.main_output, ps.adj_output, ps.time_window + FROM profiles p + LEFT JOIN pairing_settings ps ON p.name = ps.profile + ''') + rows = cursor.fetchall() + profiles = {} for r in rows: profile_name = r[0] profiles[profile_name] = { - "tab1_target": r[1], "tab2_target": r[2], "tab2_control": r[3], + "tab1_target": r[1], "tab2_target": r[2], "tab2_control": r[3], "tab4_source": r[4], "tab4_out": r[5], "mode": r[6], - "tab5_source": r[7], "tab5_out": r[8] + "tab5_source": r[7], "tab5_out": r[8], + # Pairing settings from JOIN (with defaults for NULL) + "pair_adjacent_folder": r[9] or "", + "pair_main_category": r[10] or "control", + "pair_adj_category": r[11] or "control", + "pair_main_output": r[12] or "/storage", + "pair_adj_output": r[13] or "/storage", + "pair_time_window": r[14] or 60 } - - # Load pairing settings for this profile - cursor.execute("SELECT * FROM pairing_settings WHERE profile = ?", (profile_name,)) - pair_row = cursor.fetchone() - if pair_row: - profiles[profile_name]["pair_adjacent_folder"] = pair_row[1] or "" - profiles[profile_name]["pair_main_category"] = pair_row[2] or "control" - profiles[profile_name]["pair_adj_category"] = pair_row[3] or "control" - profiles[profile_name]["pair_main_output"] = pair_row[4] or "/storage" - profiles[profile_name]["pair_adj_output"] = pair_row[5] or "/storage" - profiles[profile_name]["pair_time_window"] = pair_row[6] or 60 - + conn.close() return profiles @@ -354,40 +384,33 @@ class SorterEngine: @staticmethod def stage_image(original_path, category, new_name): """Records a pending rename/move in the database.""" - conn = sqlite3.connect(SorterEngine.DB_PATH) - cursor = conn.cursor() - cursor.execute("INSERT OR REPLACE INTO staging_area VALUES (?, ?, ?, 1)", (original_path, category, new_name)) - conn.commit() - conn.close() + with SorterEngine.get_db() as conn: + cursor = conn.cursor() + cursor.execute("INSERT OR REPLACE INTO staging_area VALUES (?, ?, ?, 1)", (original_path, category, new_name)) @staticmethod def clear_staged_item(original_path): """Removes an item from the pending staging area.""" - conn = sqlite3.connect(SorterEngine.DB_PATH) - cursor = conn.cursor() - cursor.execute("DELETE FROM staging_area WHERE original_path = ?", (original_path,)) - conn.commit() - conn.close() + with SorterEngine.get_db() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM staging_area WHERE original_path = ?", (original_path,)) @staticmethod def clear_staging_area(): """Clears all items from the staging area.""" - conn = sqlite3.connect(SorterEngine.DB_PATH) - cursor = conn.cursor() - cursor.execute("DELETE FROM staging_area") - conn.commit() - conn.close() + with SorterEngine.get_db() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM staging_area") @staticmethod def get_staged_data(): """Retrieves current tagged/staged images.""" - conn = sqlite3.connect(SorterEngine.DB_PATH) - cursor = conn.cursor() - cursor.execute("SELECT * FROM staging_area") - rows = cursor.fetchall() - conn.close() - # FIXED: Added "marked": r[3] to the dictionary - return {r[0]: {"cat": r[1], "name": r[2], "marked": r[3]} for r in rows} + with SorterEngine.get_db() as conn: + cursor = conn.cursor() + cursor.execute("SELECT * FROM staging_area") + rows = cursor.fetchall() + # FIXED: Added "marked": r[3] to the dictionary + return {r[0]: {"cat": r[1], "name": r[2], "marked": r[3]} for r in rows} @staticmethod def commit_global(output_root, cleanup_mode, operation="Copy", source_root=None, profile=None): diff --git a/gallery_app.py b/gallery_app.py index c03bc38..b4efe3b 100644 --- a/gallery_app.py +++ b/gallery_app.py @@ -1,7 +1,8 @@ import os import math import asyncio -from typing import Optional, List, Dict, Set +from typing import Optional, List, Dict, Set, Tuple +from functools import partial from nicegui import ui, app, run from fastapi import Response from engine import SorterEngine @@ -48,6 +49,12 @@ class AppState: self.staged_data: Dict = {} self.green_dots: Set[int] = set() self.index_map: Dict[int, str] = {} + + # Performance caches (Phase 1 optimizations) + self._cached_tagged_count: int = 0 # Cached count for get_stats() + self._green_dots_dirty: bool = True # Lazy green dots calculation + self._last_disk_scan_key: str = "" # Track output_dir + category for lazy disk scan + self._disk_index_map: Dict[int, str] = {} # Cached disk scan results # UI Containers (populated later) self.sidebar_container = None @@ -59,7 +66,7 @@ class AppState: self.pair_time_window = 60 # seconds +/- for matching self.pair_current_idx = 0 # Current image index in pairing mode self.pair_adjacent_folder = "" # Path to adjacent folder - self.pair_adjacent_images: List[str] = [] # Images from adjacent folder + self.pair_adjacent_data: List[Tuple[str, float]] = [] # (path, timestamp) tuples for O(1) lookup self.pair_matches: List[str] = [] # Current matches for selected image self.pair_selected_match = None # Currently selected match self.pairing_container = None # UI container for pairing mode @@ -165,11 +172,23 @@ class AppState: return filtered[start : start + self.page_size] def get_stats(self) -> Dict: - """Get image statistics for display.""" + """Get image statistics for display. Uses cached tagged count.""" total = len(self.all_images) - tagged = len([img for img in self.all_images if img in self.staged_data]) + tagged = self._cached_tagged_count return {"total": total, "tagged": tagged, "untagged": total - tagged} + def get_green_dots(self) -> Set[int]: + """Lazily calculate green dots (pages with tagged images). + Only recalculates when _green_dots_dirty is True.""" + if self._green_dots_dirty: + self.green_dots.clear() + staged_keys = set(self.staged_data.keys()) + for idx, img_path in enumerate(self.all_images): + if img_path in staged_keys: + self.green_dots.add(idx // self.page_size) + self._green_dots_dirty = False + return self.green_dots + state = AppState() # ========================================== @@ -237,36 +256,46 @@ def get_file_timestamp(filepath: str) -> Optional[float]: return None def load_adjacent_folder(): - """Load images from adjacent folder for pairing, excluding main folder.""" + """Load images from adjacent folder for pairing, excluding main folder. + Caches timestamps at load time to avoid repeated syscalls during navigation.""" if not state.pair_adjacent_folder or not os.path.exists(state.pair_adjacent_folder): - state.pair_adjacent_images = [] + state.pair_adjacent_data = [] ui.notify("Adjacent folder path is empty or doesn't exist", type='warning') return - + # Exclude the main source folder to avoid duplicates exclude = [state.source_dir] if state.source_dir else [] - - state.pair_adjacent_images = SorterEngine.get_images( - state.pair_adjacent_folder, - recursive=True, + + images = SorterEngine.get_images( + state.pair_adjacent_folder, + recursive=True, exclude_paths=exclude ) - ui.notify(f"Loaded {len(state.pair_adjacent_images)} images from adjacent folder", type='info') + + # Cache timestamps at load time (one-time cost instead of per-navigation) + state.pair_adjacent_data = [] + for img_path in images: + ts = get_file_timestamp(img_path) + if ts is not None: + state.pair_adjacent_data.append((img_path, ts)) + + ui.notify(f"Loaded {len(state.pair_adjacent_data)} images from adjacent folder", type='info') def find_time_matches(source_image: str) -> List[str]: - """Find images in adjacent folder within time window of source image.""" + """Find images in adjacent folder within time window of source image. + Uses cached timestamps from pair_adjacent_data for O(n) without syscalls.""" source_time = get_file_timestamp(source_image) if source_time is None: return [] - + + window = state.pair_time_window matches = [] - for adj_image in state.pair_adjacent_images: - adj_time = get_file_timestamp(adj_image) - if adj_time is not None: - time_diff = abs(source_time - adj_time) - if time_diff <= state.pair_time_window: - matches.append((adj_image, time_diff)) - + # Use pre-cached timestamps - no syscalls needed + for adj_path, adj_time in state.pair_adjacent_data: + time_diff = abs(source_time - adj_time) + if time_diff <= window: + matches.append((adj_path, time_diff)) + # Sort by time difference (closest first) matches.sort(key=lambda x: x[1]) return [m[0] for m in matches] @@ -459,47 +488,62 @@ def select_match(match_path: str): state.pair_selected_match = match_path render_pairing_view() -def refresh_staged_info(): - """Update staged data and index maps.""" +def refresh_staged_info(force_disk_scan: bool = False): + """Update staged data and index maps. + + Args: + force_disk_scan: If True, rescan disk even if category hasn't changed. + Set this after APPLY operations that modify files. + """ state.staged_data = SorterEngine.get_staged_data() - - # Update green dots (pages with staged images) - state.green_dots.clear() staged_keys = set(state.staged_data.keys()) - for idx, img_path in enumerate(state.all_images): - if img_path in staged_keys: - state.green_dots.add(idx // state.page_size) - + + # Update cached tagged count (O(n) but simpler than set intersection) + state._cached_tagged_count = sum(1 for img in state.all_images if img in staged_keys) + + # Mark green dots as dirty (lazy calculation) + state._green_dots_dirty = True + # Build index map for active category (gallery mode) state.index_map.clear() - + # Add staged images for orig_path, info in state.staged_data.items(): if info['cat'] == state.active_cat: idx = _extract_index(info['name']) if idx is not None: state.index_map[idx] = orig_path - - # Add committed images from disk - cat_path = os.path.join(state.output_dir, state.active_cat) - if os.path.exists(cat_path): - for filename in os.listdir(cat_path): - if filename.startswith(state.active_cat): - idx = _extract_index(filename) - if idx is not None and idx not in state.index_map: - state.index_map[idx] = os.path.join(cat_path, filename) - + + # Lazy disk scan: only rescan when output_dir+category changes or forced + disk_scan_key = f"{state.output_dir}:{state.active_cat}" + cache_valid = state._last_disk_scan_key == disk_scan_key + if not cache_valid or force_disk_scan: + state._last_disk_scan_key = disk_scan_key + state._disk_index_map.clear() + cat_path = os.path.join(state.output_dir, state.active_cat) + if os.path.exists(cat_path): + for filename in os.listdir(cat_path): + if filename.startswith(state.active_cat): + idx = _extract_index(filename) + if idx is not None: + state._disk_index_map[idx] = os.path.join(cat_path, filename) + + # Merge disk results into index_map (staged takes precedence) + for idx, path in state._disk_index_map.items(): + if idx not in state.index_map: + state.index_map[idx] = path + # Build pairing mode index map (both categories) state.pair_index_map.clear() - + for orig_path, info in state.staged_data.items(): idx = _extract_index(info['name']) if idx is None: continue - + if idx not in state.pair_index_map: state.pair_index_map[idx] = {"main": None, "adj": None} - + # Check if this is from main or adjacent category if info['cat'] == state.pair_main_category: state.pair_index_map[idx]["main"] = orig_path @@ -543,13 +587,15 @@ def action_tag(img_path: str, manual_idx: Optional[int] = None): state.undo_stack.pop(0) SorterEngine.stage_image(img_path, state.active_cat, name) - + # Only auto-increment if we used the default next_index (not manual) if manual_idx is None: state.next_index = idx + 1 - + refresh_staged_info() - refresh_ui() + # Use targeted refresh - sidebar index grid needs update, but skip heavy rebuild + render_sidebar() # Update index grid to show new tag + refresh_grid_only() # Just grid + pagination stats def action_untag(img_path: str): """Remove staging from an image.""" @@ -568,7 +614,9 @@ def action_untag(img_path: str): SorterEngine.clear_staged_item(img_path) refresh_staged_info() - refresh_ui() + # Use targeted refresh - sidebar index grid needs update + render_sidebar() # Update index grid to show removed tag + refresh_grid_only() # Just grid + pagination stats def action_delete(img_path: str): """Delete image to trash.""" @@ -632,9 +680,11 @@ def action_apply_page(): if not batch: ui.notify("No images on current page", type='warning') return - + SorterEngine.commit_batch(batch, state.output_dir, state.cleanup_mode, state.batch_mode) ui.notify(f"Page processed ({state.batch_mode})", type='positive') + # Force disk rescan since files were committed + state._last_disk_scan_key = "" load_images() async def action_apply_global(): @@ -648,6 +698,8 @@ async def action_apply_global(): state.source_dir, state.profile_name ) + # Force disk rescan since files were committed + state._last_disk_scan_key = "" load_images() ui.notify("Global apply complete!", type='positive') @@ -991,42 +1043,51 @@ def render_gallery(): for img_path in batch: render_image_card(img_path) +def _set_hovered(path: str): + """Helper for hover tracking - used with partial for memory efficiency.""" + state.hovered_image = path + +def _clear_hovered(): + """Helper for hover tracking - used with partial for memory efficiency.""" + state.hovered_image = None + def render_image_card(img_path: str): - """Render individual image card.""" + """Render individual image card. + Uses functools.partial instead of lambdas for better memory efficiency.""" is_staged = img_path in state.staged_data thumb_size = 800 - + card = ui.card().classes('p-2 bg-gray-900 border border-gray-700 no-shadow hover:border-green-500 transition-colors') - + with card: - # Track hover for keyboard shortcuts - card.on('mouseenter', lambda p=img_path: setattr(state, 'hovered_image', p)) - card.on('mouseleave', lambda: setattr(state, 'hovered_image', None)) - + # Track hover for keyboard shortcuts - using partial instead of lambda + card.on('mouseenter', partial(_set_hovered, img_path)) + card.on('mouseleave', _clear_hovered) + # Header with filename and actions with ui.row().classes('w-full justify-between no-wrap mb-1'): ui.label(os.path.basename(img_path)[:15]).classes('text-xs text-gray-400 truncate') with ui.row().classes('gap-0'): ui.button( icon='zoom_in', - on_click=lambda p=img_path: open_zoom_dialog(p) + on_click=partial(open_zoom_dialog, img_path) ).props('flat size=sm dense color=white') ui.button( icon='delete', - on_click=lambda p=img_path: action_delete(p) + on_click=partial(action_delete, img_path) ).props('flat size=sm dense color=red') - + # Thumbnail with double-click to tag img = ui.image(f"/thumbnail?path={img_path}&size={thumb_size}&q={state.preview_quality}") \ .classes('w-full h-64 bg-black rounded cursor-pointer') \ .props('fit=contain no-spinner') - - # Double-click to tag (if not already tagged) + + # Double-click to tag (if not already tagged) - using partial if not is_staged: - img.on('dblclick', lambda p=img_path: action_tag(p)) + img.on('dblclick', partial(action_tag, img_path)) else: - img.on('dblclick', lambda p=img_path: action_untag(p)) - + img.on('dblclick', partial(action_untag, img_path)) + # Tagging UI if is_staged: info = state.staged_data[img_path] @@ -1035,12 +1096,13 @@ def render_image_card(img_path: str): ui.label(f"🏷️ {info['cat']}").classes('text-center text-green-400 text-xs py-1 w-full') ui.button( f"Untag (#{idx_str})", - on_click=lambda p=img_path: action_untag(p) + on_click=partial(action_untag, img_path) ).props('flat color=grey-5 dense').classes('w-full') else: with ui.row().classes('w-full no-wrap mt-2 gap-1'): local_idx = ui.number(value=state.next_index, precision=0) \ .props('dense dark outlined').classes('w-1/3') + # Note: This one still needs lambda due to dynamic local_idx.value access ui.button( 'Tag', on_click=lambda p=img_path, i=local_idx: action_tag(p, int(i.value)) @@ -1108,8 +1170,9 @@ def render_pagination(): start = max(0, state.page - 2) end = min(state.total_pages, state.page + 3) + green_dots = state.get_green_dots() # Lazy calculation for p in range(start, end): - dot = " 🟢" if p in state.green_dots else "" + dot = " 🟢" if p in green_dots else "" color = "white" if p == state.page else "grey-6" ui.button( f"{p+1}{dot}", @@ -1131,6 +1194,12 @@ def refresh_ui(): render_pagination() render_gallery() +def refresh_grid_only(): + """Refresh only the grid and pagination stats - skip sidebar rebuild. + Use for tag/untag operations where sidebar doesn't need full rebuild.""" + render_pagination() + render_gallery() + def handle_keyboard(e): """Handle keyboard navigation and shortcuts (fallback).""" if not e.action.keydown: