Compare commits

...

91 Commits

Author SHA1 Message Date
Ethanfel 0c2504ff83 Fix caption cache timing, commit path tracking, and clean up start script
- Move caption cache refresh before UI render so indicators show on load
- Return actual dest paths from commit_batch/commit_global to fix
  caption-on-apply silently failing when files are renamed on collision
- Simplify start.sh to only run NiceGUI (remove Streamlit)
- Add requests to requirements.txt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 18:59:05 +01:00
Ethanfel 43772aba68 caption fix 2026-01-28 16:15:42 +01:00
Ethanfel 145368692e caption 2026-01-28 16:06:56 +01:00
Ethanfel bf1134e47f claude 2026-01-28 15:42:14 +01:00
Ethanfel 7580036c9d Update engine.py 2026-01-23 13:52:39 +01:00
Ethanfel 47a75b428e Update gallery_app.py 2026-01-23 13:52:25 +01:00
Ethanfel b91a2f0a31 Update engine.py 2026-01-23 13:42:43 +01:00
Ethanfel 66795471a8 Update gallery_app.py 2026-01-23 13:42:28 +01:00
Ethanfel 67acb8e08a Update gallery_app.py 2026-01-23 13:35:55 +01:00
Ethanfel af0cc52d89 Update gallery_app.py 2026-01-23 13:27:04 +01:00
Ethanfel 3669814731 Add engine.py 2026-01-23 13:26:45 +01:00
Ethanfel a8edc251a2 Delete engine.py 2026-01-23 13:26:23 +01:00
Ethanfel d43813cc2a Merge pull request 'hotkey' (#8) from hotkey into main
Reviewed-on: #8
2026-01-23 13:19:26 +01:00
Ethanfel 97424ea0af Update gallery_app.py 2026-01-23 12:54:08 +01:00
Ethanfel eafc5de6f2 Update gallery_app.py 2026-01-23 12:49:56 +01:00
Ethanfel fa710e914e Update gallery_app.py 2026-01-23 12:41:48 +01:00
Ethanfel e3e337af88 Update gallery_app.py 2026-01-23 12:39:49 +01:00
Ethanfel 15ca74ad4b Merge pull request 'profile' (#7) from profile into main
Reviewed-on: #7
2026-01-23 12:39:02 +01:00
Ethanfel a11d76fd5f revert cf1238bbff
revert Update gallery_app.py
2026-01-22 15:39:03 +01:00
Ethanfel cf1238bbff Update gallery_app.py 2026-01-22 15:35:32 +01:00
Ethanfel d3b7f31730 Update gallery_app.py 2026-01-22 15:30:16 +01:00
Ethanfel 52c06c4db7 Update gallery_app.py 2026-01-22 15:24:22 +01:00
Ethanfel 3a320f3187 Merge pull request 'profile' (#6) from profile into main
Reviewed-on: #6
2026-01-22 15:21:15 +01:00
Ethanfel c37e2bd5e0 Update gallery_app.py 2026-01-22 15:17:04 +01:00
Ethanfel 9418661be9 Update engine.py 2026-01-22 15:14:43 +01:00
Ethanfel 7349015177 Update gallery_app.py 2026-01-22 15:14:30 +01:00
Ethanfel 918a6e9414 revert 5909c0ec99
revert Update gallery_app.py
2026-01-22 15:14:19 +01:00
Ethanfel 5909c0ec99 Update gallery_app.py 2026-01-22 15:14:01 +01:00
Ethanfel 286b0410ff Update gallery_app.py 2026-01-22 13:03:00 +01:00
Ethanfel 0c18f570d4 revert 3f2160405a
revert Update gallery_app.py
2026-01-22 12:59:06 +01:00
Ethanfel 3f2160405a Update gallery_app.py 2026-01-22 12:58:48 +01:00
Ethanfel f3f57f7c53 Update gallery_app.py 2026-01-22 12:57:23 +01:00
Ethanfel 957aab0656 Update gallery_app.py 2026-01-22 12:50:51 +01:00
Ethanfel 0a94548f5e Update engine.py 2026-01-22 12:50:16 +01:00
Ethanfel 124fbacd2a Merge pull request 'tag' (#5) from tag into main
Reviewed-on: #5
2026-01-22 12:49:02 +01:00
Ethanfel 0f0aeed2f1 Update gallery_app.py 2026-01-20 16:08:21 +01:00
Ethanfel fe6e55de16 Update gallery_app.py 2026-01-20 16:03:03 +01:00
Ethanfel dd454ebf6f Update engine.py 2026-01-20 13:54:06 +01:00
Ethanfel 2854907359 Update gallery_app.py 2026-01-20 13:53:51 +01:00
Ethanfel 48417b6d73 Update engine.py 2026-01-20 13:27:41 +01:00
Ethanfel ce7abd8a29 Update gallery_app.py 2026-01-20 13:27:30 +01:00
Ethanfel df12413c5d Update engine.py 2026-01-20 13:23:34 +01:00
Ethanfel c56b07f999 Update engine.py 2026-01-20 13:21:11 +01:00
Ethanfel c89cecd43f Update engine.py 2026-01-20 13:19:02 +01:00
Ethanfel 37f6166b37 Update gallery_app.py 2026-01-20 13:17:22 +01:00
Ethanfel dc31b0bebb Update engine.py 2026-01-20 13:17:06 +01:00
Ethanfel f0b0114fc5 Merge pull request 'nicegui' (#4) from nicegui into main
Reviewed-on: #4
2026-01-20 11:48:11 +01:00
Ethanfel 0c9446b3f8 Update gallery_app.py 2026-01-20 11:27:57 +01:00
Ethanfel 4c49635018 Update gallery_app.py 2026-01-20 10:47:09 +01:00
Ethanfel 826ae384df Update tab_gallery_sorter.py 2026-01-20 01:32:34 +01:00
Ethanfel 54ba10d4e5 clause sonet 2026-01-20 01:27:24 +01:00
Ethanfel 0e6de4ae0b Update gallery_app.py 2026-01-19 22:55:10 +01:00
Ethanfel b919c52255 Update gallery_app.py 2026-01-19 22:35:08 +01:00
Ethanfel 8fc8372a9b Update gallery_app.py 2026-01-19 22:29:48 +01:00
Ethanfel 246b78719e Update gallery_app.py 2026-01-19 21:00:55 +01:00
Ethanfel 0d5f393aff Update gallery_app.py 2026-01-19 20:56:33 +01:00
Ethanfel 4fb038eda1 Update gallery_app.py 2026-01-19 20:24:32 +01:00
Ethanfel 690aaafacf Update gallery_app.py 2026-01-19 20:21:46 +01:00
Ethanfel 3e9ff43bc9 Update gallery_app.py 2026-01-19 20:08:03 +01:00
Ethanfel 91a0cc5138 Update gallery_app.py 2026-01-19 20:06:23 +01:00
Ethanfel 588822f856 Update gallery_app.py 2026-01-19 20:03:47 +01:00
Ethanfel 1cbad1a3ed Update gallery_app.py 2026-01-19 19:59:55 +01:00
Ethanfel b5794e9db5 Update gallery_app.py 2026-01-19 19:54:42 +01:00
Ethanfel b938dc68fa Update gallery_app.py 2026-01-19 19:36:47 +01:00
Ethanfel dde0e90442 Update gallery_app.py 2026-01-19 19:34:08 +01:00
Ethanfel 0b5e9377e4 Add start.sh 2026-01-19 19:31:29 +01:00
Ethanfel 091936069a Update gallery_app.py 2026-01-19 19:26:23 +01:00
Ethanfel 0d1eca4ef3 Add gallery_app.py 2026-01-19 19:25:01 +01:00
Ethanfel 39153d3493 Update requirements.txt 2026-01-19 19:24:25 +01:00
Ethanfel af2c148747 Merge pull request 'carrousel' (#3) from carrousel into main
Reviewed-on: #3
2026-01-19 17:29:57 +01:00
Ethanfel bf845292ee Update tab_gallery_sorter.py 2026-01-19 16:46:13 +01:00
Ethanfel 40453dad94 Update tab_gallery_sorter.py 2026-01-19 16:32:35 +01:00
Ethanfel 13818737e2 Update tab_gallery_sorter.py 2026-01-19 16:14:17 +01:00
Ethanfel e4b126075d Update tab_gallery_sorter.py 2026-01-19 16:10:27 +01:00
Ethanfel 04a29d7424 Update tab_gallery_sorter.py 2026-01-19 16:03:30 +01:00
Ethanfel a6314cadd9 Update tab_gallery_sorter.py 2026-01-19 15:46:19 +01:00
Ethanfel 024caac5e5 Update tab_gallery_sorter.py 2026-01-19 15:39:41 +01:00
Ethanfel c9a2817f41 Update engine.py 2026-01-19 15:38:34 +01:00
Ethanfel 69f34a84c4 Update tab_gallery_sorter.py 2026-01-19 15:36:25 +01:00
Ethanfel 470e3114c4 Update tab_gallery_sorter.py 2026-01-19 15:32:22 +01:00
Ethanfel ac189d75ba Update engine.py 2026-01-19 15:29:38 +01:00
Ethanfel 28e8722a10 Merge pull request 'speed' (#2) from speed into main
Reviewed-on: #2
2026-01-19 15:27:43 +01:00
Ethanfel b909069174 Update tab_gallery_sorter.py 2026-01-19 15:21:55 +01:00
Ethanfel 9c86eb4b72 Update engine.py 2026-01-19 14:59:43 +01:00
Ethanfel 4636a79ada Update tab_gallery_sorter.py 2026-01-19 14:58:52 +01:00
Ethanfel ff27a3bc83 Update engine.py 2026-01-19 14:50:00 +01:00
Ethanfel 7eb71cab56 Update tab_gallery_sorter.py 2026-01-19 14:32:56 +01:00
Ethanfel 758125a60b webp 2026-01-19 14:28:22 +01:00
Ethanfel e7144eb6cf webp 2026-01-19 14:27:25 +01:00
Ethanfel 8328e4d3b4 Update tab_gallery_sorter.py 2026-01-19 14:24:25 +01:00
Ethanfel 6363ea4590 Update engine.py 2026-01-19 14:22:24 +01:00
8 changed files with 3499 additions and 244 deletions
+61
View 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
Binary file not shown.
Binary file not shown.
+834 -42
View File
File diff suppressed because it is too large Load Diff
+1877
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,2 +1,4 @@
streamlit streamlit
Pillow Pillow
nicegui
requests
Executable
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
# NiceSorter - Start Script
# Runs the NiceGUI gallery interface on port 8080
set -e
# Navigate to app directory if running in container
if [ -d "/app" ]; then
cd /app
fi
# Install dependencies if requirements.txt exists
if [ -f "requirements.txt" ]; then
echo "📦 Installing dependencies..."
pip install --no-cache-dir -q -r requirements.txt
fi
# Initialize database
echo "🗄️ Initializing database..."
python3 -c "from engine import SorterEngine; SorterEngine.init_db()"
# Start NiceGUI
echo "🚀 Starting NiceSorter on http://0.0.0.0:8080"
exec python3 gallery_app.py
+646 -148
View File
@@ -1,245 +1,743 @@
import streamlit as st import streamlit as st
import os import os
import math import math
import concurrent.futures
from typing import Dict, Set, List, Optional, Tuple
from engine import SorterEngine from engine import SorterEngine
# ========================================== # ==========================================
# 1. CACHED DATA LOADER (The Fix) # STATE MANAGEMENT
# ========================================== # ==========================================
@st.cache_data(show_spinner=False)
def get_cached_images(path, mutation_id):
"""
Scans the folder ONLY when 'path' or 'mutation_id' changes.
Navigating pages does NOT change these, so it remains instant.
"""
return SorterEngine.get_images(path, recursive=True)
class StreamlitState:
"""Centralized state management with type hints."""
# ========================================== @staticmethod
# 2. CALLBACKS (Updated with Refresh Logic) def init():
# ========================================== """Initialize all session state variables."""
defaults = {
't5_file_id': 0,
't5_page': 0,
't5_active_cat': 'Default',
't5_next_index': 1,
't5_op_mode': 'Copy',
't5_cleanup_mode': 'Keep',
't5_page_size': 24,
't5_grid_cols': 4,
't5_quality': 50,
}
for key, value in defaults.items():
if key not in st.session_state:
st.session_state[key] = value
@staticmethod
def trigger_refresh(): def trigger_refresh():
"""Increments the mutation counter to force a file re-scan.""" """Force file cache invalidation."""
if 't5_file_id' not in st.session_state: st.session_state.t5_file_id = 0
st.session_state.t5_file_id += 1 st.session_state.t5_file_id += 1
def cb_tag_image(img_path, selected_cat): @staticmethod
if selected_cat.startswith("---") or selected_cat == "": def change_page(delta: int):
"""Navigate pages by delta."""
st.session_state.t5_page = max(0, st.session_state.t5_page + delta)
@staticmethod
def set_page(page_idx: int):
"""Jump to specific page."""
st.session_state.t5_page = page_idx
@staticmethod
def slider_change(key: str):
"""Handle slider-based page navigation (1-based to 0-based)."""
st.session_state.t5_page = st.session_state[key] - 1
# ==========================================
# CACHING & DATA LOADING
# ==========================================
@st.cache_data(show_spinner=False)
def get_cached_images(path: str, mutation_id: int) -> List[str]:
"""Scan folder for images. mutation_id forces refresh."""
return SorterEngine.get_images(path, recursive=True)
@st.cache_data(show_spinner=False, max_entries=2000)
def get_cached_thumbnail(path: str, quality: int, target_size: int, mtime: float) -> Optional[bytes]:
"""Load and compress thumbnail with caching."""
try:
return SorterEngine.compress_for_web(path, quality, target_size)
except Exception:
return None
@st.cache_data(show_spinner=False)
def get_cached_green_dots(all_images: List[str], page_size: int, staged_keys: frozenset) -> Set[int]:
"""
Calculate which pages have tagged images (cached).
Returns set of page indices with staged images.
"""
staged_set = set(staged_keys)
tagged_pages = set()
for idx, img_path in enumerate(all_images):
if img_path in staged_set:
tagged_pages.add(idx // page_size)
return tagged_pages
@st.cache_data(show_spinner=False)
def build_index_map(active_cat: str, path_o: str, staged_data_frozen: frozenset) -> Dict[int, str]:
"""
Build mapping of index numbers to file paths for active category.
Returns: {1: '/path/to/Cat_001.jpg', 2: '/path/to/Cat_002.jpg', ...}
"""
index_map = {}
# Convert frozenset back to dict for processing
staged_dict = {k: v for k, v in staged_data_frozen}
# Check staging area
for orig_path, info in staged_dict.items():
if info['cat'] == active_cat:
idx = _extract_index(info['name'])
if idx is not None:
index_map[idx] = orig_path
# Check disk
cat_path = os.path.join(path_o, active_cat)
if os.path.exists(cat_path):
for filename in os.listdir(cat_path):
if filename.startswith(active_cat):
idx = _extract_index(filename)
if idx is not None and idx not in index_map:
index_map[idx] = os.path.join(cat_path, filename)
return index_map
def _extract_index(filename: str) -> Optional[int]:
"""Extract numeric index from filename (e.g., 'Cat_042.jpg' -> 42)."""
try:
parts = filename.rsplit('_', 1)
if len(parts) > 1:
num_str = parts[1].split('.')[0]
return int(num_str)
except (ValueError, IndexError):
pass
return None
# ==========================================
# ACTIONS
# ==========================================
def action_tag(img_path: str, selected_cat: str, index_val: int, path_o: str):
"""Tag image with category and index, handling collisions."""
if selected_cat.startswith("---") or not selected_cat:
st.toast("⚠️ Select a valid category first!", icon="🚫") st.toast("⚠️ Select a valid category first!", icon="🚫")
return return
staged = SorterEngine.get_staged_data()
ext = os.path.splitext(img_path)[1]
count = len([v for v in staged.values() if v['cat'] == selected_cat]) + 1
new_name = f"{selected_cat}_{count:03d}{ext}"
SorterEngine.stage_image(img_path, selected_cat, new_name)
# Note: Tagging does NOT need a file re-scan, just a grid refresh.
def cb_untag_image(img_path): ext = os.path.splitext(img_path)[1]
base_name = f"{selected_cat}_{index_val:03d}"
new_name = f"{base_name}{ext}"
# Collision detection
staged = SorterEngine.get_staged_data()
staged_names = {v['name'] for v in staged.values() if v['cat'] == selected_cat}
dest_path = os.path.join(path_o, selected_cat, new_name)
collision = False
suffix = 1
while new_name in staged_names or os.path.exists(dest_path):
collision = True
new_name = f"{base_name}_{suffix}{ext}"
dest_path = os.path.join(path_o, selected_cat, new_name)
suffix += 1
SorterEngine.stage_image(img_path, selected_cat, new_name)
if collision:
st.toast(f"⚠️ Conflict! Saved as: {new_name}", icon="🔀")
# Auto-increment index
st.session_state.t5_next_index = index_val + 1
def action_untag(img_path: str):
"""Remove staging from image."""
SorterEngine.clear_staged_item(img_path) SorterEngine.clear_staged_item(img_path)
def cb_delete_image(img_path): def action_delete(img_path: str):
"""Delete image to trash."""
SorterEngine.delete_to_trash(img_path) SorterEngine.delete_to_trash(img_path)
trigger_refresh() # Force re-scan so the image disappears from the list StreamlitState.trigger_refresh()
def cb_apply_batch(current_batch, path_o, cleanup_mode, operation): def action_apply_batch(current_batch: List[str], path_o: str, cleanup_mode: str, operation: str):
"""Apply staged changes for current page."""
SorterEngine.commit_batch(current_batch, path_o, cleanup_mode, operation) SorterEngine.commit_batch(current_batch, path_o, cleanup_mode, operation)
trigger_refresh() # Force re-scan to remove moved files StreamlitState.trigger_refresh()
def cb_apply_global(path_o, cleanup_mode, operation, path_s): def action_apply_global(path_o: str, cleanup_mode: str, operation: str, path_s: str):
"""Apply all staged changes globally."""
SorterEngine.commit_global(path_o, cleanup_mode, operation, source_root=path_s) SorterEngine.commit_global(path_o, cleanup_mode, operation, source_root=path_s)
trigger_refresh() # Force re-scan StreamlitState.trigger_refresh()
def cb_change_page(delta): def action_add_category(name: str):
if 't5_page' not in st.session_state: st.session_state.t5_page = 0 """Add new category."""
st.session_state.t5_page += delta if name:
# No trigger_refresh() here -> This is why page turning is now instant! SorterEngine.add_category(name)
st.session_state.t5_active_cat = name
def cb_jump_page(k): def action_rename_category(old_name: str, new_name: str):
val = st.session_state[k] """Rename category."""
st.session_state.t5_page = val - 1 if new_name and new_name != old_name:
SorterEngine.rename_category(old_name, new_name)
st.session_state.t5_active_cat = new_name
def action_delete_category(cat_name: str):
"""Delete category."""
SorterEngine.delete_category(cat_name)
# Reset to first available category
cats = SorterEngine.get_categories() or ["Default"]
st.session_state.t5_active_cat = cats[0]
# ========================================== # ==========================================
# 3. FRAGMENTS (Sidebar, Grid, Batch) # DIALOGS
# ========================================== # ==========================================
# ... (Sidebar code remains exactly the same) ...
@st.dialog("🔍 Full Resolution", width="large")
def view_high_res(img_path: str):
"""Modal for full resolution inspection."""
img_data = SorterEngine.compress_for_web(img_path, quality=90, target_size=None)
if img_data:
st.image(img_data, use_container_width=True)
st.caption(f"📁 {img_path}")
else:
st.error(f"Could not load: {img_path}")
@st.dialog("🖼️ Tag Preview", width="large")
def view_tag_preview(img_path: str, title: str):
"""Show image associated with a numbered tag."""
st.subheader(title)
img_data = SorterEngine.compress_for_web(img_path, quality=80, target_size=800)
if img_data:
st.image(img_data, use_container_width=True)
st.caption(f"📁 {img_path}")
else:
st.error(f"Could not load: {img_path}")
# ==========================================
# UI COMPONENTS
# ==========================================
@st.fragment @st.fragment
def render_sidebar_content(): def render_sidebar_content(path_o: str):
"""Render category management sidebar."""
st.divider() st.divider()
st.subheader("🏷️ Category Manager") st.subheader("🏷️ Category Manager")
cats = SorterEngine.get_categories()
processed_cats = [] # Get and process categories with separators
cats = SorterEngine.get_categories() or ["Default"]
processed_cats = _add_category_separators(cats)
# Sync radio selection immediately
if "t5_radio_select" in st.session_state:
new_selection = st.session_state.t5_radio_select
if not new_selection.startswith("---"):
st.session_state.t5_active_cat = new_selection
if "t5_active_cat" not in st.session_state:
st.session_state.t5_active_cat = cats[0]
current_cat = st.session_state.t5_active_cat
# NUMBER GRID (1-25) with previews
if current_cat and not current_cat.startswith("---"):
st.caption(f"**{current_cat}** Index Map")
# Build index map (cached)
staged = SorterEngine.get_staged_data()
staged_frozen = frozenset(staged.items())
index_map = build_index_map(current_cat, path_o, staged_frozen)
# Render 5x5 grid
grid_cols = st.columns(5, gap="small")
for i in range(1, 26):
is_used = i in index_map
btn_type = "primary" if is_used else "secondary"
with grid_cols[(i - 1) % 5]:
if st.button(f"{i}", key=f"grid_{i}", type=btn_type, use_container_width=True):
st.session_state.t5_next_index = i
if is_used:
view_tag_preview(index_map[i], f"{current_cat} #{i}")
else:
st.toast(f"Next index set to #{i}")
st.divider()
# CATEGORY SELECTOR
st.radio("Active Category", processed_cats, key="t5_radio_select")
# INDEX CONTROLS
st.caption("Tagging Settings")
c_num1, c_num2 = st.columns([3, 1], vertical_alignment="bottom")
c_num1.number_input("Next Index #", min_value=1, step=1, key="t5_next_index")
if c_num2.button("🔄", help="Auto-detect next index"):
used_indices = list(index_map.keys()) if index_map else []
st.session_state.t5_next_index = max(used_indices) + 1 if used_indices else 1
st.rerun()
st.divider()
# CATEGORY MANAGEMENT TABS
tab_add, tab_edit = st.tabs([" Add", "✏️ Edit"])
with tab_add:
c1, c2 = st.columns([3, 1])
new_cat = c1.text_input(
"New Category",
label_visibility="collapsed",
placeholder="Enter name...",
key="t5_new_cat"
)
if c2.button("Add", key="btn_add_cat"):
action_add_category(new_cat)
st.rerun()
with tab_edit:
if current_cat and not current_cat.startswith("---") and current_cat in cats:
st.caption(f"Editing: **{current_cat}**")
rename_val = st.text_input(
"Rename to:",
value=current_cat,
key=f"ren_{current_cat}"
)
if st.button("💾 Save", key=f"save_{current_cat}", use_container_width=True):
action_rename_category(current_cat, rename_val)
st.rerun()
st.markdown("---")
if st.button(
"🗑️ Delete Category",
key=f"del_cat_{current_cat}",
type="primary",
use_container_width=True
):
action_delete_category(current_cat)
st.rerun()
def _add_category_separators(cats: List[str]) -> List[str]:
"""Add alphabetical separators between categories."""
processed = []
last_char = "" last_char = ""
if cats:
for cat in cats: for cat in cats:
current_char = cat[0].upper() current_char = cat[0].upper()
if last_char and current_char != last_char: if last_char and current_char != last_char:
processed_cats.append(f"--- {current_char} ---") processed.append(f"--- {current_char} ---")
processed_cats.append(cat) processed.append(cat)
last_char = current_char last_char = current_char
if "t5_active_cat" not in st.session_state: st.session_state.t5_active_cat = cats[0] if cats else "Default" return processed
current_selection = st.session_state.t5_active_cat
if not current_selection.startswith("---") and current_selection not in cats:
st.session_state.t5_active_cat = cats[0] if cats else "Default"
selection = st.radio("Active Tag", processed_cats, key="t5_radio_select") def render_pagination_carousel(key_suffix: str, total_pages: int, current_page: int, tagged_pages: Set[int]):
if not selection.startswith("---"): st.session_state.t5_active_cat = selection """Render pagination controls with green dot indicators."""
if total_pages <= 1:
return
st.divider() # Rapid navigation slider (1-based)
tab_add, tab_edit = st.tabs([" Add", "✏️ Edit"]) st.slider(
with tab_add: "Page Navigator",
c1, c2 = st.columns([3, 1]) min_value=1,
new_cat = c1.text_input("New Name", label_visibility="collapsed", placeholder="New...", key="t5_new_cat") max_value=total_pages,
if c2.button("Add", key="btn_add_cat"): value=current_page + 1,
if new_cat: step=1,
SorterEngine.add_category(new_cat) key=f"slider_{key_suffix}",
st.rerun() label_visibility="collapsed",
with tab_edit: on_change=StreamlitState.slider_change,
target_cat = st.session_state.t5_active_cat args=(f"slider_{key_suffix}",)
if target_cat and not target_cat.startswith("---") and target_cat in cats: )
st.caption(f"Editing: **{target_cat}**")
rename_val = st.text_input("Rename to:", value=target_cat, key=f"ren_{target_cat}")
if st.button("💾 Save", key=f"save_{target_cat}", use_container_width=True):
if rename_val and rename_val != target_cat:
SorterEngine.rename_category(target_cat, rename_val)
st.session_state.t5_active_cat = rename_val
st.rerun()
st.markdown("---")
if st.button("🗑️ Delete", key=f"del_cat_{target_cat}", type="primary", use_container_width=True):
SorterEngine.delete_category(target_cat)
st.rerun()
else:
st.info("Select a valid category to edit.")
# Calculate button window (show current ±2 pages)
window_radius = 2
start_p = max(0, current_page - window_radius)
end_p = min(total_pages, current_page + window_radius + 1)
# Adjust near edges to maintain consistent width
if current_page < window_radius:
end_p = min(total_pages, 5)
elif current_page > total_pages - window_radius - 1:
start_p = max(0, total_pages - 5)
num_buttons = end_p - start_p
if num_buttons < 1:
start_p = 0
end_p = total_pages
num_buttons = total_pages
# Render button row: [Prev] [1] [2] [3] ... [Next]
cols = st.columns([1] + [1] * num_buttons + [1])
# Previous button
with cols[0]:
st.button(
"",
disabled=(current_page == 0),
on_click=StreamlitState.change_page,
args=(-1,),
key=f"prev_{key_suffix}",
use_container_width=True
)
# Page number buttons
for i, p_idx in enumerate(range(start_p, end_p)):
with cols[i + 1]:
label = str(p_idx + 1)
if p_idx in tagged_pages:
label += " 🟢"
btn_type = "primary" if p_idx == current_page else "secondary"
st.button(
label,
type=btn_type,
key=f"btn_p{p_idx}_{key_suffix}",
use_container_width=True,
on_click=StreamlitState.set_page,
args=(p_idx,)
)
# Next button
with cols[-1]:
st.button(
"",
disabled=(current_page >= total_pages - 1),
on_click=StreamlitState.change_page,
args=(1,),
key=f"next_{key_suffix}",
use_container_width=True
)
# ... (Gallery Grid code remains exactly the same) ...
@st.fragment @st.fragment
def render_gallery_grid(current_batch, quality, grid_cols): def render_gallery_grid(
current_batch: List[str],
quality: int,
grid_cols: int,
path_o: str
):
"""Render image gallery grid with parallel loading."""
staged = SorterEngine.get_staged_data() staged = SorterEngine.get_staged_data()
history = SorterEngine.get_processed_log() history = SorterEngine.get_processed_log()
selected_cat = st.session_state.get("t5_active_cat", "Default") selected_cat = st.session_state.t5_active_cat
tagging_disabled = selected_cat.startswith("---") tagging_disabled = selected_cat.startswith("---")
# --- NEW: LOAD ALL IMAGES IN PARALLEL --- target_size = int(2400 / grid_cols)
# This runs multithreaded and is much faster than the old loop
batch_cache = SorterEngine.load_batch_parallel(current_batch, quality)
# Parallel thumbnail loading
batch_cache = _load_thumbnails_parallel(current_batch, quality, target_size)
# Render grid
cols = st.columns(grid_cols) cols = st.columns(grid_cols)
for idx, img_path in enumerate(current_batch): for idx, img_path in enumerate(current_batch):
unique_key = f"frag_{os.path.basename(img_path)}"
with cols[idx % grid_cols]: with cols[idx % grid_cols]:
_render_image_card(
img_path=img_path,
batch_cache=batch_cache,
staged=staged,
history=history,
selected_cat=selected_cat,
tagging_disabled=tagging_disabled,
path_o=path_o
)
def _load_thumbnails_parallel(
batch: List[str],
quality: int,
target_size: int
) -> Dict[str, Optional[bytes]]:
"""Load thumbnails in parallel using ThreadPoolExecutor."""
batch_cache = {}
def fetch_one(path: str) -> Tuple[str, Optional[bytes]]:
try:
mtime = os.path.getmtime(path)
data = get_cached_thumbnail(path, quality, target_size, mtime)
return path, data
except Exception:
return path, None
with concurrent.futures.ThreadPoolExecutor(max_workers=16) as executor:
futures = {executor.submit(fetch_one, p): p for p in batch}
for future in concurrent.futures.as_completed(futures):
path, data = future.result()
batch_cache[path] = data
return batch_cache
def _render_image_card(
img_path: str,
batch_cache: Dict[str, Optional[bytes]],
staged: Dict,
history: Dict,
selected_cat: str,
tagging_disabled: bool,
path_o: str
):
"""Render individual image card."""
unique_key = f"frag_{os.path.basename(img_path)}"
is_staged = img_path in staged is_staged = img_path in staged
is_processed = img_path in history is_processed = img_path in history
with st.container(border=True): with st.container(border=True):
c_head1, c_head2 = st.columns([5, 1]) # Header: filename + zoom + delete
c_head1.caption(os.path.basename(img_path)[:15]) c_name, c_zoom, c_del = st.columns([4, 1, 1])
c_head2.button("", key=f"del_{unique_key}", on_click=cb_delete_image, args=(img_path,)) c_name.caption(os.path.basename(img_path)[:15])
if c_zoom.button("🔍", key=f"zoom_{unique_key}"):
view_high_res(img_path)
c_del.button(
"",
key=f"del_{unique_key}",
on_click=action_delete,
args=(img_path,)
)
# Status indicator
if is_staged: if is_staged:
st.success(f"🏷️ {staged[img_path]['cat']}") staged_info = staged[img_path]
idx = _extract_index(staged_info['name'])
idx_str = f" #{idx}" if idx else ""
st.success(f"🏷️ {staged_info['cat']}{idx_str}")
elif is_processed: elif is_processed:
st.info(f"{history[img_path]['action']} -> {history[img_path]['cat']}") st.info(f"{history[img_path]['action']}")
# --- CHANGED: USE PRE-LOADED DATA --- # Thumbnail
img_data = batch_cache.get(img_path) img_data = batch_cache.get(img_path)
if img_data: if img_data:
st.image(img_data, use_container_width=True) st.image(img_data, use_container_width=True)
if not is_staged:
st.button("Tag", key=f"tag_{unique_key}", disabled=tagging_disabled, use_container_width=True,
on_click=cb_tag_image, args=(img_path, selected_cat))
else: else:
st.button("Untag", key=f"untag_{unique_key}", use_container_width=True, st.error("Failed to load")
on_click=cb_untag_image, args=(img_path,))
# Action buttons
if not is_staged:
c_idx, c_tag = st.columns([1, 2], vertical_alignment="bottom")
card_index = c_idx.number_input(
"Index",
min_value=1,
step=1,
value=st.session_state.t5_next_index,
label_visibility="collapsed",
key=f"idx_{unique_key}"
)
c_tag.button(
"Tag",
key=f"tag_{unique_key}",
disabled=tagging_disabled,
use_container_width=True,
on_click=action_tag,
args=(img_path, selected_cat, card_index, path_o)
)
else:
# Show untag with index number
staged_name = staged[img_path]['name']
idx = _extract_index(staged_name)
untag_label = f"Untag (#{idx})" if idx else "Untag"
st.button(
untag_label,
key=f"untag_{unique_key}",
use_container_width=True,
on_click=action_untag,
args=(img_path,)
)
# ... (Batch Actions code remains exactly the same) ...
@st.fragment @st.fragment
def render_batch_actions(current_batch, path_o, page_num, path_s): def render_batch_actions(
st.write(f"### 🚀 Processing Actions") current_batch: List[str],
st.caption("Settings apply to both Page and Global actions.") path_o: str,
page_num: int,
path_s: str
):
"""Render batch processing controls."""
st.write("### 🚀 Processing Actions")
st.caption("Settings apply to both Page and Global actions")
c_set1, c_set2 = st.columns(2) c_set1, c_set2 = st.columns(2)
op_mode = c_set1.radio("Tagged Files:", ["Move", "Copy"], horizontal=True, key="t5_op_mode")
cleanup = c_set2.radio("Untagged Files:", ["Keep", "Move to Unused", "Delete"], horizontal=True, key="t5_cleanup_mode") c_set1.radio(
"Tagged Files:",
["Copy", "Move"],
horizontal=True,
key="t5_op_mode"
)
c_set2.radio(
"Untagged Files:",
["Keep", "Move to Unused", "Delete"],
horizontal=True,
key="t5_cleanup_mode"
)
st.divider() st.divider()
c_btn1, c_btn2 = st.columns(2) c_btn1, c_btn2 = st.columns(2)
if c_btn1.button(f"APPLY PAGE {page_num}", type="secondary", use_container_width=True, # Apply Page button
on_click=cb_apply_batch, args=(current_batch, path_o, cleanup, op_mode)): if c_btn1.button(
st.toast(f"Page {page_num} Applied!") f"APPLY PAGE {page_num}",
type="secondary",
use_container_width=True,
on_click=action_apply_batch,
args=(
current_batch,
path_o,
st.session_state.t5_cleanup_mode,
st.session_state.t5_op_mode
)
):
st.toast(f"Page {page_num} applied!")
st.rerun() st.rerun()
if c_btn2.button("APPLY ALL (GLOBAL)", type="primary", use_container_width=True, # Apply Global button
help="Process ALL tagged files across all pages.", if c_btn2.button(
on_click=cb_apply_global, args=(path_o, cleanup, op_mode, path_s)): "APPLY ALL (GLOBAL)",
st.toast("Global Apply Complete!") type="primary",
use_container_width=True,
help="Process ALL tagged files",
on_click=action_apply_global,
args=(
path_o,
st.session_state.t5_cleanup_mode,
st.session_state.t5_op_mode,
path_s
)
):
st.toast("Global apply complete!")
st.rerun() st.rerun()
# ==========================================
# MAIN RENDER FUNCTION
# ==========================================
# ========================================== def render(quality: int, profile_name: str):
# 4. MAIN RENDERER """Main render function for Streamlit app."""
# ==========================================
def render(quality, profile_name):
st.subheader("🖼️ Gallery Staging Sorter") st.subheader("🖼️ Gallery Staging Sorter")
# Init Mutation ID (This triggers the scanner cache refresh) # Initialize state
if 't5_file_id' not in st.session_state: st.session_state.t5_file_id = 0 StreamlitState.init()
if 't5_page' not in st.session_state: st.session_state.t5_page = 0
# Load profiles and paths
profiles = SorterEngine.load_profiles() profiles = SorterEngine.load_profiles()
p_data = profiles.get(profile_name, {}) p_data = profiles.get(profile_name, {})
c1, c2 = st.columns(2)
path_s = c1.text_input("Source Folder", value=p_data.get("tab5_source", "/storage"), key="t5_s")
path_o = c2.text_input("Output Folder", value=p_data.get("tab5_out", "/storage"), key="t5_o")
if path_s != p_data.get("tab5_source") or path_o != p_data.get("tab5_out"): c1, c2, c3 = st.columns([3, 3, 1])
if st.button("💾 Save Settings"):
path_s = c1.text_input(
"Source Folder",
value=p_data.get("tab5_source", "/storage"),
key="t5_s"
)
path_o = c2.text_input(
"Output Folder",
value=p_data.get("tab5_out", "/storage"),
key="t5_o"
)
# Save settings button
if c3.button("💾 Save", use_container_width=True):
SorterEngine.save_tab_paths(profile_name, t5_s=path_s, t5_o=path_o) SorterEngine.save_tab_paths(profile_name, t5_s=path_s, t5_o=path_o)
# Saving settings might mean new folder, so we trigger refresh StreamlitState.trigger_refresh()
trigger_refresh() st.toast("Settings saved!")
st.rerun() st.rerun()
if not os.path.exists(path_s): return # Validate source path
if not os.path.exists(path_s):
st.warning("⚠️ Source path does not exist")
return
# Render sidebar
with st.sidebar: with st.sidebar:
render_sidebar_content() render_sidebar_content(path_o)
with st.expander("👀 View Settings"): # View settings
c_v1, c_v2 = st.columns(2) with st.expander("👀 View Settings", expanded=False):
page_size = c_v1.slider("Images per Page", 12, 100, 24, 4) c_v1, c_v2, c_v3 = st.columns(3)
grid_cols = c_v2.slider("Grid Columns", 2, 8, 4)
# --- USING CACHED LOADER --- st.session_state.t5_page_size = c_v1.slider(
# We pass the mutation ID. If ID is same as last run, scan is SKIPPED. "Images/Page",
12, 100,
st.session_state.t5_page_size,
4
)
st.session_state.t5_grid_cols = c_v2.slider(
"Grid Columns",
2, 8,
st.session_state.t5_grid_cols
)
st.session_state.t5_quality = c_v3.slider(
"Preview Quality",
10, 100,
st.session_state.t5_quality,
10
)
# Load images (cached)
all_images = get_cached_images(path_s, st.session_state.t5_file_id) all_images = get_cached_images(path_s, st.session_state.t5_file_id)
if not all_images: if not all_images:
st.info("No images found.") st.info("📂 No images found in source folder")
return return
total_items = len(all_images) # Pagination calculations
total_pages = math.ceil(total_items / page_size) page_size = st.session_state.t5_page_size
if st.session_state.t5_page >= total_pages: st.session_state.t5_page = max(0, total_pages - 1) total_pages = math.ceil(len(all_images) / page_size)
if st.session_state.t5_page < 0: st.session_state.t5_page = 0
start_idx = st.session_state.t5_page * page_size # Bounds checking
end_idx = start_idx + page_size if st.session_state.t5_page >= total_pages:
current_batch = all_images[start_idx:end_idx] st.session_state.t5_page = max(0, total_pages - 1)
if st.session_state.t5_page < 0:
st.session_state.t5_page = 0
def nav_controls(key_suffix): current_page = st.session_state.t5_page
c1, c2, c3, c4 = st.columns([1.5, 1, 0.5, 1.5], vertical_alignment="center") start_idx = current_page * page_size
c1.button("⬅️ Prev", disabled=(st.session_state.t5_page == 0), on_click=cb_change_page, args=(-1,), key=f"p_{key_suffix}", use_container_width=True) current_batch = all_images[start_idx : start_idx + page_size]
c2.number_input("Page", min_value=1, max_value=total_pages, value=st.session_state.t5_page + 1, step=1, label_visibility="collapsed", key=f"jump_{key_suffix}", on_change=cb_jump_page, args=(f"jump_{key_suffix}",))
c3.markdown(f"<div style='text-align: left; font-weight: bold;'>/ {total_pages}</div>", unsafe_allow_html=True)
c4.button("Next ➡️", disabled=(st.session_state.t5_page >= total_pages - 1), on_click=cb_change_page, args=(1,), key=f"n_{key_suffix}", use_container_width=True)
st.divider() # Calculate green dots (cached)
nav_controls("top") staged = SorterEngine.get_staged_data()
render_gallery_grid(current_batch, quality, grid_cols) green_dots = get_cached_green_dots(
st.divider() all_images,
nav_controls("bottom") page_size,
frozenset(staged.keys())
)
# Render UI components
st.divider() st.divider()
render_batch_actions(current_batch, path_o, st.session_state.t5_page + 1, path_s) # Top pagination
render_pagination_carousel("top", total_pages, current_page, green_dots)
# Gallery grid
render_gallery_grid(
current_batch,
st.session_state.t5_quality,
st.session_state.t5_grid_cols,
path_o
)
st.divider()
# Bottom pagination
render_pagination_carousel("bot", total_pages, current_page, green_dots)
st.divider()
# Batch actions
render_batch_actions(current_batch, path_o, current_page + 1, path_s)