Compare commits

...

91 Commits

Author SHA1 Message Date
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
43772aba68 caption fix 2026-01-28 16:15:42 +01:00
145368692e caption 2026-01-28 16:06:56 +01:00
bf1134e47f claude 2026-01-28 15:42:14 +01:00
7580036c9d Update engine.py 2026-01-23 13:52:39 +01:00
47a75b428e Update gallery_app.py 2026-01-23 13:52:25 +01:00
b91a2f0a31 Update engine.py 2026-01-23 13:42:43 +01:00
66795471a8 Update gallery_app.py 2026-01-23 13:42:28 +01:00
67acb8e08a Update gallery_app.py 2026-01-23 13:35:55 +01:00
af0cc52d89 Update gallery_app.py 2026-01-23 13:27:04 +01:00
3669814731 Add engine.py 2026-01-23 13:26:45 +01:00
a8edc251a2 Delete engine.py 2026-01-23 13:26:23 +01:00
d43813cc2a Merge pull request 'hotkey' (#8) from hotkey into main
Reviewed-on: #8
2026-01-23 13:19:26 +01:00
97424ea0af Update gallery_app.py 2026-01-23 12:54:08 +01:00
eafc5de6f2 Update gallery_app.py 2026-01-23 12:49:56 +01:00
fa710e914e Update gallery_app.py 2026-01-23 12:41:48 +01:00
e3e337af88 Update gallery_app.py 2026-01-23 12:39:49 +01:00
15ca74ad4b Merge pull request 'profile' (#7) from profile into main
Reviewed-on: #7
2026-01-23 12:39:02 +01:00
a11d76fd5f revert cf1238bbff
revert Update gallery_app.py
2026-01-22 15:39:03 +01:00
cf1238bbff Update gallery_app.py 2026-01-22 15:35:32 +01:00
d3b7f31730 Update gallery_app.py 2026-01-22 15:30:16 +01:00
52c06c4db7 Update gallery_app.py 2026-01-22 15:24:22 +01:00
3a320f3187 Merge pull request 'profile' (#6) from profile into main
Reviewed-on: #6
2026-01-22 15:21:15 +01:00
c37e2bd5e0 Update gallery_app.py 2026-01-22 15:17:04 +01:00
9418661be9 Update engine.py 2026-01-22 15:14:43 +01:00
7349015177 Update gallery_app.py 2026-01-22 15:14:30 +01:00
918a6e9414 revert 5909c0ec99
revert Update gallery_app.py
2026-01-22 15:14:19 +01:00
5909c0ec99 Update gallery_app.py 2026-01-22 15:14:01 +01:00
286b0410ff Update gallery_app.py 2026-01-22 13:03:00 +01:00
0c18f570d4 revert 3f2160405a
revert Update gallery_app.py
2026-01-22 12:59:06 +01:00
3f2160405a Update gallery_app.py 2026-01-22 12:58:48 +01:00
f3f57f7c53 Update gallery_app.py 2026-01-22 12:57:23 +01:00
957aab0656 Update gallery_app.py 2026-01-22 12:50:51 +01:00
0a94548f5e Update engine.py 2026-01-22 12:50:16 +01:00
124fbacd2a Merge pull request 'tag' (#5) from tag into main
Reviewed-on: #5
2026-01-22 12:49:02 +01:00
0f0aeed2f1 Update gallery_app.py 2026-01-20 16:08:21 +01:00
fe6e55de16 Update gallery_app.py 2026-01-20 16:03:03 +01:00
dd454ebf6f Update engine.py 2026-01-20 13:54:06 +01:00
2854907359 Update gallery_app.py 2026-01-20 13:53:51 +01:00
48417b6d73 Update engine.py 2026-01-20 13:27:41 +01:00
ce7abd8a29 Update gallery_app.py 2026-01-20 13:27:30 +01:00
df12413c5d Update engine.py 2026-01-20 13:23:34 +01:00
c56b07f999 Update engine.py 2026-01-20 13:21:11 +01:00
c89cecd43f Update engine.py 2026-01-20 13:19:02 +01:00
37f6166b37 Update gallery_app.py 2026-01-20 13:17:22 +01:00
dc31b0bebb Update engine.py 2026-01-20 13:17:06 +01:00
f0b0114fc5 Merge pull request 'nicegui' (#4) from nicegui into main
Reviewed-on: #4
2026-01-20 11:48:11 +01:00
0c9446b3f8 Update gallery_app.py 2026-01-20 11:27:57 +01:00
4c49635018 Update gallery_app.py 2026-01-20 10:47:09 +01:00
826ae384df Update tab_gallery_sorter.py 2026-01-20 01:32:34 +01:00
54ba10d4e5 clause sonet 2026-01-20 01:27:24 +01:00
0e6de4ae0b Update gallery_app.py 2026-01-19 22:55:10 +01:00
b919c52255 Update gallery_app.py 2026-01-19 22:35:08 +01:00
8fc8372a9b Update gallery_app.py 2026-01-19 22:29:48 +01:00
246b78719e Update gallery_app.py 2026-01-19 21:00:55 +01:00
0d5f393aff Update gallery_app.py 2026-01-19 20:56:33 +01:00
4fb038eda1 Update gallery_app.py 2026-01-19 20:24:32 +01:00
690aaafacf Update gallery_app.py 2026-01-19 20:21:46 +01:00
3e9ff43bc9 Update gallery_app.py 2026-01-19 20:08:03 +01:00
91a0cc5138 Update gallery_app.py 2026-01-19 20:06:23 +01:00
588822f856 Update gallery_app.py 2026-01-19 20:03:47 +01:00
1cbad1a3ed Update gallery_app.py 2026-01-19 19:59:55 +01:00
b5794e9db5 Update gallery_app.py 2026-01-19 19:54:42 +01:00
b938dc68fa Update gallery_app.py 2026-01-19 19:36:47 +01:00
dde0e90442 Update gallery_app.py 2026-01-19 19:34:08 +01:00
0b5e9377e4 Add start.sh 2026-01-19 19:31:29 +01:00
091936069a Update gallery_app.py 2026-01-19 19:26:23 +01:00
0d1eca4ef3 Add gallery_app.py 2026-01-19 19:25:01 +01:00
39153d3493 Update requirements.txt 2026-01-19 19:24:25 +01:00
af2c148747 Merge pull request 'carrousel' (#3) from carrousel into main
Reviewed-on: #3
2026-01-19 17:29:57 +01:00
bf845292ee Update tab_gallery_sorter.py 2026-01-19 16:46:13 +01:00
40453dad94 Update tab_gallery_sorter.py 2026-01-19 16:32:35 +01:00
13818737e2 Update tab_gallery_sorter.py 2026-01-19 16:14:17 +01:00
e4b126075d Update tab_gallery_sorter.py 2026-01-19 16:10:27 +01:00
04a29d7424 Update tab_gallery_sorter.py 2026-01-19 16:03:30 +01:00
a6314cadd9 Update tab_gallery_sorter.py 2026-01-19 15:46:19 +01:00
024caac5e5 Update tab_gallery_sorter.py 2026-01-19 15:39:41 +01:00
c9a2817f41 Update engine.py 2026-01-19 15:38:34 +01:00
69f34a84c4 Update tab_gallery_sorter.py 2026-01-19 15:36:25 +01:00
470e3114c4 Update tab_gallery_sorter.py 2026-01-19 15:32:22 +01:00
ac189d75ba Update engine.py 2026-01-19 15:29:38 +01:00
28e8722a10 Merge pull request 'speed' (#2) from speed into main
Reviewed-on: #2
2026-01-19 15:27:43 +01:00
b909069174 Update tab_gallery_sorter.py 2026-01-19 15:21:55 +01:00
9c86eb4b72 Update engine.py 2026-01-19 14:59:43 +01:00
4636a79ada Update tab_gallery_sorter.py 2026-01-19 14:58:52 +01:00
ff27a3bc83 Update engine.py 2026-01-19 14:50:00 +01:00
7eb71cab56 Update tab_gallery_sorter.py 2026-01-19 14:32:56 +01:00
758125a60b webp 2026-01-19 14:28:22 +01:00
e7144eb6cf webp 2026-01-19 14:27:25 +01:00
8328e4d3b4 Update tab_gallery_sorter.py 2026-01-19 14:24:25 +01:00
6363ea4590 Update engine.py 2026-01-19 14:22:24 +01:00
8 changed files with 3499 additions and 244 deletions

61
CLAUDE.md Normal file
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.

908
engine.py

File diff suppressed because it is too large Load Diff

1877
gallery_app.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,4 @@
streamlit streamlit
Pillow Pillow
nicegui
requests

25
start.sh Executable file
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

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
# ========================================== # ==========================================
class StreamlitState:
"""Centralized state management with type hints."""
@staticmethod
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():
"""Force file cache invalidation."""
st.session_state.t5_file_id += 1
@staticmethod
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) @st.cache_data(show_spinner=False)
def get_cached_images(path, mutation_id): def get_cached_images(path: str, mutation_id: int) -> List[str]:
""" """Scan folder for images. mutation_id forces refresh."""
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) 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
# ========================================== # ==========================================
# 2. CALLBACKS (Updated with Refresh Logic) # ACTIONS
# ========================================== # ==========================================
def trigger_refresh():
"""Increments the mutation counter to force a file re-scan."""
if 't5_file_id' not in st.session_state: st.session_state.t5_file_id = 0
st.session_state.t5_file_id += 1
def cb_tag_image(img_path, selected_cat): def action_tag(img_path: str, selected_cat: str, index_val: int, path_o: str):
if selected_cat.startswith("---") or selected_cat == "": """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 = []
last_char = ""
if cats:
for cat in cats:
current_char = cat[0].upper()
if last_char and current_char != last_char:
processed_cats.append(f"--- {current_char} ---")
processed_cats.append(cat)
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" # Get and process categories with separators
current_selection = st.session_state.t5_active_cat cats = SorterEngine.get_categories() or ["Default"]
if not current_selection.startswith("---") and current_selection not in cats: processed_cats = _add_category_separators(cats)
st.session_state.t5_active_cat = cats[0] if cats else "Default"
selection = st.radio("Active Tag", processed_cats, key="t5_radio_select") # Sync radio selection immediately
if not selection.startswith("---"): st.session_state.t5_active_cat = selection 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() st.divider()
# CATEGORY MANAGEMENT TABS
tab_add, tab_edit = st.tabs([" Add", "✏️ Edit"]) tab_add, tab_edit = st.tabs([" Add", "✏️ Edit"])
with tab_add: with tab_add:
c1, c2 = st.columns([3, 1]) c1, c2 = st.columns([3, 1])
new_cat = c1.text_input("New Name", label_visibility="collapsed", placeholder="New...", key="t5_new_cat") 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"): if c2.button("Add", key="btn_add_cat"):
if new_cat: action_add_category(new_cat)
SorterEngine.add_category(new_cat)
st.rerun()
with tab_edit:
target_cat = st.session_state.t5_active_cat
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.")
# ... (Gallery Grid code remains exactly the same) ...
@st.fragment
def render_gallery_grid(current_batch, quality, grid_cols):
staged = SorterEngine.get_staged_data()
history = SorterEngine.get_processed_log()
selected_cat = st.session_state.get("t5_active_cat", "Default")
tagging_disabled = selected_cat.startswith("---")
# --- NEW: LOAD ALL IMAGES IN PARALLEL ---
# This runs multithreaded and is much faster than the old loop
batch_cache = SorterEngine.load_batch_parallel(current_batch, quality)
cols = st.columns(grid_cols)
for idx, img_path in enumerate(current_batch):
unique_key = f"frag_{os.path.basename(img_path)}"
with cols[idx % grid_cols]:
is_staged = img_path in staged
is_processed = img_path in history
with st.container(border=True):
c_head1, c_head2 = st.columns([5, 1])
c_head1.caption(os.path.basename(img_path)[:15])
c_head2.button("", key=f"del_{unique_key}", on_click=cb_delete_image, args=(img_path,))
if is_staged:
st.success(f"🏷️ {staged[img_path]['cat']}")
elif is_processed:
st.info(f"{history[img_path]['action']} -> {history[img_path]['cat']}")
# --- CHANGED: USE PRE-LOADED DATA ---
img_data = batch_cache.get(img_path)
if img_data:
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:
st.button("Untag", key=f"untag_{unique_key}", use_container_width=True,
on_click=cb_untag_image, args=(img_path,))
# ... (Batch Actions code remains exactly the same) ...
@st.fragment
def render_batch_actions(current_batch, path_o, page_num, path_s):
st.write(f"### 🚀 Processing Actions")
st.caption("Settings apply to both Page and Global actions.")
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")
st.divider()
c_btn1, c_btn2 = st.columns(2)
if c_btn1.button(f"APPLY PAGE {page_num}", type="secondary", use_container_width=True,
on_click=cb_apply_batch, args=(current_batch, path_o, cleanup, op_mode)):
st.toast(f"Page {page_num} Applied!")
st.rerun()
if c_btn2.button("APPLY ALL (GLOBAL)", type="primary", use_container_width=True,
help="Process ALL tagged files across all pages.",
on_click=cb_apply_global, args=(path_o, cleanup, op_mode, path_s)):
st.toast("Global Apply Complete!")
st.rerun()
# ==========================================
# 4. MAIN RENDERER
# ==========================================
def render(quality, profile_name):
st.subheader("🖼️ Gallery Staging Sorter")
# Init Mutation ID (This triggers the scanner cache refresh)
if 't5_file_id' not in st.session_state: st.session_state.t5_file_id = 0
if 't5_page' not in st.session_state: st.session_state.t5_page = 0
profiles = SorterEngine.load_profiles()
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"):
if st.button("💾 Save Settings"):
SorterEngine.save_tab_paths(profile_name, t5_s=path_s, t5_o=path_o)
# Saving settings might mean new folder, so we trigger refresh
trigger_refresh()
st.rerun() st.rerun()
if not os.path.exists(path_s): return 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 = ""
for cat in cats:
current_char = cat[0].upper()
if last_char and current_char != last_char:
processed.append(f"--- {current_char} ---")
processed.append(cat)
last_char = current_char
return processed
def render_pagination_carousel(key_suffix: str, total_pages: int, current_page: int, tagged_pages: Set[int]):
"""Render pagination controls with green dot indicators."""
if total_pages <= 1:
return
# Rapid navigation slider (1-based)
st.slider(
"Page Navigator",
min_value=1,
max_value=total_pages,
value=current_page + 1,
step=1,
key=f"slider_{key_suffix}",
label_visibility="collapsed",
on_change=StreamlitState.slider_change,
args=(f"slider_{key_suffix}",)
)
# 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
)
@st.fragment
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()
history = SorterEngine.get_processed_log()
selected_cat = st.session_state.t5_active_cat
tagging_disabled = selected_cat.startswith("---")
target_size = int(2400 / grid_cols)
# Parallel thumbnail loading
batch_cache = _load_thumbnails_parallel(current_batch, quality, target_size)
# Render grid
cols = st.columns(grid_cols)
for idx, img_path in enumerate(current_batch):
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_processed = img_path in history
with st.container(border=True):
# Header: filename + zoom + delete
c_name, c_zoom, c_del = st.columns([4, 1, 1])
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:
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:
st.info(f"{history[img_path]['action']}")
# Thumbnail
img_data = batch_cache.get(img_path)
if img_data:
st.image(img_data, use_container_width=True)
else:
st.error("Failed to load")
# 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,)
)
@st.fragment
def render_batch_actions(
current_batch: List[str],
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.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()
c_btn1, c_btn2 = st.columns(2)
# Apply Page button
if c_btn1.button(
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()
# Apply Global button
if c_btn2.button(
"APPLY ALL (GLOBAL)",
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()
# ==========================================
# MAIN RENDER FUNCTION
# ==========================================
def render(quality: int, profile_name: str):
"""Main render function for Streamlit app."""
st.subheader("🖼️ Gallery Staging Sorter")
# Initialize state
StreamlitState.init()
# Load profiles and paths
profiles = SorterEngine.load_profiles()
p_data = profiles.get(profile_name, {})
c1, c2, c3 = st.columns([3, 3, 1])
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)
StreamlitState.trigger_refresh()
st.toast("Settings saved!")
st.rerun()
# 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)