claude
This commit is contained in:
61
CLAUDE.md
Normal file
61
CLAUDE.md
Normal file
@@ -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
|
||||||
BIN
__pycache__/engine.cpython-312.pyc
Normal file
BIN
__pycache__/engine.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/gallery_app.cpython-312.pyc
Normal file
BIN
__pycache__/gallery_app.cpython-312.pyc
Normal file
Binary file not shown.
77
engine.py
77
engine.py
@@ -1,12 +1,28 @@
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from contextlib import contextmanager
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
class SorterEngine:
|
class SorterEngine:
|
||||||
DB_PATH = "/app/sorter_database.db"
|
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 ---
|
# --- 1. DATABASE INITIALIZATION ---
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def init_db():
|
def init_db():
|
||||||
@@ -52,6 +68,14 @@ class SorterEngine:
|
|||||||
for cat in ["_TRASH", "control", "Default", "Action", "Solo"]:
|
for cat in ["_TRASH", "control", "Default", "Action", "Solo"]:
|
||||||
cursor.execute("INSERT OR IGNORE INTO categories VALUES (?)", (cat,))
|
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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -146,13 +170,12 @@ class SorterEngine:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def load_profiles():
|
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)
|
conn = sqlite3.connect(SorterEngine.DB_PATH)
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("SELECT * FROM profiles")
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
|
|
||||||
# Ensure pairing_settings table exists
|
# Ensure pairing_settings table exists before JOIN
|
||||||
cursor.execute('''CREATE TABLE IF NOT EXISTS pairing_settings
|
cursor.execute('''CREATE TABLE IF NOT EXISTS pairing_settings
|
||||||
(profile TEXT PRIMARY KEY,
|
(profile TEXT PRIMARY KEY,
|
||||||
adjacent_folder TEXT,
|
adjacent_folder TEXT,
|
||||||
@@ -162,26 +185,33 @@ class SorterEngine:
|
|||||||
adj_output TEXT,
|
adj_output TEXT,
|
||||||
time_window INTEGER)''')
|
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 = {}
|
profiles = {}
|
||||||
for r in rows:
|
for r in rows:
|
||||||
profile_name = r[0]
|
profile_name = r[0]
|
||||||
profiles[profile_name] = {
|
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],
|
"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()
|
conn.close()
|
||||||
return profiles
|
return profiles
|
||||||
|
|
||||||
@@ -354,38 +384,31 @@ class SorterEngine:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def stage_image(original_path, category, new_name):
|
def stage_image(original_path, category, new_name):
|
||||||
"""Records a pending rename/move in the database."""
|
"""Records a pending rename/move in the database."""
|
||||||
conn = sqlite3.connect(SorterEngine.DB_PATH)
|
with SorterEngine.get_db() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("INSERT OR REPLACE INTO staging_area VALUES (?, ?, ?, 1)", (original_path, category, new_name))
|
cursor.execute("INSERT OR REPLACE INTO staging_area VALUES (?, ?, ?, 1)", (original_path, category, new_name))
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def clear_staged_item(original_path):
|
def clear_staged_item(original_path):
|
||||||
"""Removes an item from the pending staging area."""
|
"""Removes an item from the pending staging area."""
|
||||||
conn = sqlite3.connect(SorterEngine.DB_PATH)
|
with SorterEngine.get_db() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("DELETE FROM staging_area WHERE original_path = ?", (original_path,))
|
cursor.execute("DELETE FROM staging_area WHERE original_path = ?", (original_path,))
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def clear_staging_area():
|
def clear_staging_area():
|
||||||
"""Clears all items from the staging area."""
|
"""Clears all items from the staging area."""
|
||||||
conn = sqlite3.connect(SorterEngine.DB_PATH)
|
with SorterEngine.get_db() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("DELETE FROM staging_area")
|
cursor.execute("DELETE FROM staging_area")
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_staged_data():
|
def get_staged_data():
|
||||||
"""Retrieves current tagged/staged images."""
|
"""Retrieves current tagged/staged images."""
|
||||||
conn = sqlite3.connect(SorterEngine.DB_PATH)
|
with SorterEngine.get_db() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("SELECT * FROM staging_area")
|
cursor.execute("SELECT * FROM staging_area")
|
||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
conn.close()
|
|
||||||
# FIXED: Added "marked": r[3] to the dictionary
|
# FIXED: Added "marked": r[3] to the dictionary
|
||||||
return {r[0]: {"cat": r[1], "name": r[2], "marked": r[3]} for r in rows}
|
return {r[0]: {"cat": r[1], "name": r[2], "marked": r[3]} for r in rows}
|
||||||
|
|
||||||
|
|||||||
145
gallery_app.py
145
gallery_app.py
@@ -1,7 +1,8 @@
|
|||||||
import os
|
import os
|
||||||
import math
|
import math
|
||||||
import asyncio
|
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 nicegui import ui, app, run
|
||||||
from fastapi import Response
|
from fastapi import Response
|
||||||
from engine import SorterEngine
|
from engine import SorterEngine
|
||||||
@@ -49,6 +50,12 @@ class AppState:
|
|||||||
self.green_dots: Set[int] = set()
|
self.green_dots: Set[int] = set()
|
||||||
self.index_map: Dict[int, str] = {}
|
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)
|
# UI Containers (populated later)
|
||||||
self.sidebar_container = None
|
self.sidebar_container = None
|
||||||
self.grid_container = None
|
self.grid_container = None
|
||||||
@@ -59,7 +66,7 @@ class AppState:
|
|||||||
self.pair_time_window = 60 # seconds +/- for matching
|
self.pair_time_window = 60 # seconds +/- for matching
|
||||||
self.pair_current_idx = 0 # Current image index in pairing mode
|
self.pair_current_idx = 0 # Current image index in pairing mode
|
||||||
self.pair_adjacent_folder = "" # Path to adjacent folder
|
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_matches: List[str] = [] # Current matches for selected image
|
||||||
self.pair_selected_match = None # Currently selected match
|
self.pair_selected_match = None # Currently selected match
|
||||||
self.pairing_container = None # UI container for pairing mode
|
self.pairing_container = None # UI container for pairing mode
|
||||||
@@ -165,11 +172,23 @@ class AppState:
|
|||||||
return filtered[start : start + self.page_size]
|
return filtered[start : start + self.page_size]
|
||||||
|
|
||||||
def get_stats(self) -> Dict:
|
def get_stats(self) -> Dict:
|
||||||
"""Get image statistics for display."""
|
"""Get image statistics for display. Uses cached tagged count."""
|
||||||
total = len(self.all_images)
|
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}
|
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()
|
state = AppState()
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
@@ -237,35 +256,45 @@ def get_file_timestamp(filepath: str) -> Optional[float]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def load_adjacent_folder():
|
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):
|
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')
|
ui.notify("Adjacent folder path is empty or doesn't exist", type='warning')
|
||||||
return
|
return
|
||||||
|
|
||||||
# Exclude the main source folder to avoid duplicates
|
# Exclude the main source folder to avoid duplicates
|
||||||
exclude = [state.source_dir] if state.source_dir else []
|
exclude = [state.source_dir] if state.source_dir else []
|
||||||
|
|
||||||
state.pair_adjacent_images = SorterEngine.get_images(
|
images = SorterEngine.get_images(
|
||||||
state.pair_adjacent_folder,
|
state.pair_adjacent_folder,
|
||||||
recursive=True,
|
recursive=True,
|
||||||
exclude_paths=exclude
|
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]:
|
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)
|
source_time = get_file_timestamp(source_image)
|
||||||
if source_time is None:
|
if source_time is None:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
window = state.pair_time_window
|
||||||
matches = []
|
matches = []
|
||||||
for adj_image in state.pair_adjacent_images:
|
# Use pre-cached timestamps - no syscalls needed
|
||||||
adj_time = get_file_timestamp(adj_image)
|
for adj_path, adj_time in state.pair_adjacent_data:
|
||||||
if adj_time is not None:
|
|
||||||
time_diff = abs(source_time - adj_time)
|
time_diff = abs(source_time - adj_time)
|
||||||
if time_diff <= state.pair_time_window:
|
if time_diff <= window:
|
||||||
matches.append((adj_image, time_diff))
|
matches.append((adj_path, time_diff))
|
||||||
|
|
||||||
# Sort by time difference (closest first)
|
# Sort by time difference (closest first)
|
||||||
matches.sort(key=lambda x: x[1])
|
matches.sort(key=lambda x: x[1])
|
||||||
@@ -459,16 +488,21 @@ def select_match(match_path: str):
|
|||||||
state.pair_selected_match = match_path
|
state.pair_selected_match = match_path
|
||||||
render_pairing_view()
|
render_pairing_view()
|
||||||
|
|
||||||
def refresh_staged_info():
|
def refresh_staged_info(force_disk_scan: bool = False):
|
||||||
"""Update staged data and index maps."""
|
"""Update staged data and index maps.
|
||||||
state.staged_data = SorterEngine.get_staged_data()
|
|
||||||
|
|
||||||
# Update green dots (pages with staged images)
|
Args:
|
||||||
state.green_dots.clear()
|
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()
|
||||||
staged_keys = set(state.staged_data.keys())
|
staged_keys = set(state.staged_data.keys())
|
||||||
for idx, img_path in enumerate(state.all_images):
|
|
||||||
if img_path in staged_keys:
|
# Update cached tagged count (O(n) but simpler than set intersection)
|
||||||
state.green_dots.add(idx // state.page_size)
|
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)
|
# Build index map for active category (gallery mode)
|
||||||
state.index_map.clear()
|
state.index_map.clear()
|
||||||
@@ -480,14 +514,24 @@ def refresh_staged_info():
|
|||||||
if idx is not None:
|
if idx is not None:
|
||||||
state.index_map[idx] = orig_path
|
state.index_map[idx] = orig_path
|
||||||
|
|
||||||
# Add committed images from disk
|
# 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)
|
cat_path = os.path.join(state.output_dir, state.active_cat)
|
||||||
if os.path.exists(cat_path):
|
if os.path.exists(cat_path):
|
||||||
for filename in os.listdir(cat_path):
|
for filename in os.listdir(cat_path):
|
||||||
if filename.startswith(state.active_cat):
|
if filename.startswith(state.active_cat):
|
||||||
idx = _extract_index(filename)
|
idx = _extract_index(filename)
|
||||||
if idx is not None and idx not in state.index_map:
|
if idx is not None:
|
||||||
state.index_map[idx] = os.path.join(cat_path, filename)
|
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)
|
# Build pairing mode index map (both categories)
|
||||||
state.pair_index_map.clear()
|
state.pair_index_map.clear()
|
||||||
@@ -549,7 +593,9 @@ def action_tag(img_path: str, manual_idx: Optional[int] = None):
|
|||||||
state.next_index = idx + 1
|
state.next_index = idx + 1
|
||||||
|
|
||||||
refresh_staged_info()
|
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):
|
def action_untag(img_path: str):
|
||||||
"""Remove staging from an image."""
|
"""Remove staging from an image."""
|
||||||
@@ -568,7 +614,9 @@ def action_untag(img_path: str):
|
|||||||
|
|
||||||
SorterEngine.clear_staged_item(img_path)
|
SorterEngine.clear_staged_item(img_path)
|
||||||
refresh_staged_info()
|
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):
|
def action_delete(img_path: str):
|
||||||
"""Delete image to trash."""
|
"""Delete image to trash."""
|
||||||
@@ -635,6 +683,8 @@ def action_apply_page():
|
|||||||
|
|
||||||
SorterEngine.commit_batch(batch, state.output_dir, state.cleanup_mode, state.batch_mode)
|
SorterEngine.commit_batch(batch, state.output_dir, state.cleanup_mode, state.batch_mode)
|
||||||
ui.notify(f"Page processed ({state.batch_mode})", type='positive')
|
ui.notify(f"Page processed ({state.batch_mode})", type='positive')
|
||||||
|
# Force disk rescan since files were committed
|
||||||
|
state._last_disk_scan_key = ""
|
||||||
load_images()
|
load_images()
|
||||||
|
|
||||||
async def action_apply_global():
|
async def action_apply_global():
|
||||||
@@ -648,6 +698,8 @@ async def action_apply_global():
|
|||||||
state.source_dir,
|
state.source_dir,
|
||||||
state.profile_name
|
state.profile_name
|
||||||
)
|
)
|
||||||
|
# Force disk rescan since files were committed
|
||||||
|
state._last_disk_scan_key = ""
|
||||||
load_images()
|
load_images()
|
||||||
ui.notify("Global apply complete!", type='positive')
|
ui.notify("Global apply complete!", type='positive')
|
||||||
|
|
||||||
@@ -991,17 +1043,26 @@ def render_gallery():
|
|||||||
for img_path in batch:
|
for img_path in batch:
|
||||||
render_image_card(img_path)
|
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):
|
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
|
is_staged = img_path in state.staged_data
|
||||||
thumb_size = 800
|
thumb_size = 800
|
||||||
|
|
||||||
card = ui.card().classes('p-2 bg-gray-900 border border-gray-700 no-shadow hover:border-green-500 transition-colors')
|
card = ui.card().classes('p-2 bg-gray-900 border border-gray-700 no-shadow hover:border-green-500 transition-colors')
|
||||||
|
|
||||||
with card:
|
with card:
|
||||||
# Track hover for keyboard shortcuts
|
# Track hover for keyboard shortcuts - using partial instead of lambda
|
||||||
card.on('mouseenter', lambda p=img_path: setattr(state, 'hovered_image', p))
|
card.on('mouseenter', partial(_set_hovered, img_path))
|
||||||
card.on('mouseleave', lambda: setattr(state, 'hovered_image', None))
|
card.on('mouseleave', _clear_hovered)
|
||||||
|
|
||||||
# Header with filename and actions
|
# Header with filename and actions
|
||||||
with ui.row().classes('w-full justify-between no-wrap mb-1'):
|
with ui.row().classes('w-full justify-between no-wrap mb-1'):
|
||||||
@@ -1009,11 +1070,11 @@ def render_image_card(img_path: str):
|
|||||||
with ui.row().classes('gap-0'):
|
with ui.row().classes('gap-0'):
|
||||||
ui.button(
|
ui.button(
|
||||||
icon='zoom_in',
|
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')
|
).props('flat size=sm dense color=white')
|
||||||
ui.button(
|
ui.button(
|
||||||
icon='delete',
|
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')
|
).props('flat size=sm dense color=red')
|
||||||
|
|
||||||
# Thumbnail with double-click to tag
|
# Thumbnail with double-click to tag
|
||||||
@@ -1021,11 +1082,11 @@ def render_image_card(img_path: str):
|
|||||||
.classes('w-full h-64 bg-black rounded cursor-pointer') \
|
.classes('w-full h-64 bg-black rounded cursor-pointer') \
|
||||||
.props('fit=contain no-spinner')
|
.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:
|
if not is_staged:
|
||||||
img.on('dblclick', lambda p=img_path: action_tag(p))
|
img.on('dblclick', partial(action_tag, img_path))
|
||||||
else:
|
else:
|
||||||
img.on('dblclick', lambda p=img_path: action_untag(p))
|
img.on('dblclick', partial(action_untag, img_path))
|
||||||
|
|
||||||
# Tagging UI
|
# Tagging UI
|
||||||
if is_staged:
|
if is_staged:
|
||||||
@@ -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.label(f"🏷️ {info['cat']}").classes('text-center text-green-400 text-xs py-1 w-full')
|
||||||
ui.button(
|
ui.button(
|
||||||
f"Untag (#{idx_str})",
|
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')
|
).props('flat color=grey-5 dense').classes('w-full')
|
||||||
else:
|
else:
|
||||||
with ui.row().classes('w-full no-wrap mt-2 gap-1'):
|
with ui.row().classes('w-full no-wrap mt-2 gap-1'):
|
||||||
local_idx = ui.number(value=state.next_index, precision=0) \
|
local_idx = ui.number(value=state.next_index, precision=0) \
|
||||||
.props('dense dark outlined').classes('w-1/3')
|
.props('dense dark outlined').classes('w-1/3')
|
||||||
|
# Note: This one still needs lambda due to dynamic local_idx.value access
|
||||||
ui.button(
|
ui.button(
|
||||||
'Tag',
|
'Tag',
|
||||||
on_click=lambda p=img_path, i=local_idx: action_tag(p, int(i.value))
|
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)
|
start = max(0, state.page - 2)
|
||||||
end = min(state.total_pages, state.page + 3)
|
end = min(state.total_pages, state.page + 3)
|
||||||
|
|
||||||
|
green_dots = state.get_green_dots() # Lazy calculation
|
||||||
for p in range(start, end):
|
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"
|
color = "white" if p == state.page else "grey-6"
|
||||||
ui.button(
|
ui.button(
|
||||||
f"{p+1}{dot}",
|
f"{p+1}{dot}",
|
||||||
@@ -1131,6 +1194,12 @@ def refresh_ui():
|
|||||||
render_pagination()
|
render_pagination()
|
||||||
render_gallery()
|
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):
|
def handle_keyboard(e):
|
||||||
"""Handle keyboard navigation and shortcuts (fallback)."""
|
"""Handle keyboard navigation and shortcuts (fallback)."""
|
||||||
if not e.action.keydown:
|
if not e.action.keydown:
|
||||||
|
|||||||
Reference in New Issue
Block a user