Compare commits

...

99 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
d30414d972 Merge pull request 'global-button' (#1) from global-button into main
Reviewed-on: #1
2026-01-19 14:19:42 +01:00
c25e71a4c7 Update tab_gallery_sorter.py 2026-01-19 14:17:37 +01:00
bbc784c720 Update engine.py 2026-01-19 14:17:01 +01:00
b155d90853 Update tab_gallery_sorter.py 2026-01-19 14:13:51 +01:00
c30a346a1e Update engine.py 2026-01-19 14:11:28 +01:00
19852d5353 Update engine.py 2026-01-19 14:10:00 +01:00
8662e61690 Update tab_gallery_sorter.py 2026-01-19 14:07:22 +01:00
612da36a77 Update engine.py 2026-01-19 14:06:54 +01:00
8 changed files with 3561 additions and 318 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.

1040
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,289 +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. GLOBAL CALLBACKS (Prevents Page Refresh) # STATE MANAGEMENT
# ========================================== # ==========================================
def cb_tag_image(img_path, selected_cat): class StreamlitState:
"""Tags an image. Updates DB immediately.""" """Centralized state management with type hints."""
if selected_cat.startswith("---") or selected_cat == "":
@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)
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]
# Auto-increment logic ext = os.path.splitext(img_path)[1]
count = len([v for v in staged.values() if v['cat'] == selected_cat]) + 1 base_name = f"{selected_cat}_{index_val:03d}"
new_name = f"{selected_cat}_{count:03d}{ext}" 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) 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 cb_untag_image(img_path): def action_untag(img_path: str):
"""Untags an image.""" """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):
"""Moves image to trash.""" """Delete image to trash."""
SorterEngine.delete_to_trash(img_path) SorterEngine.delete_to_trash(img_path)
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):
"""Commits the batch with the specified operation (Move/Copy).""" """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)
StreamlitState.trigger_refresh()
def cb_change_page(delta): def action_apply_global(path_o: str, cleanup_mode: str, operation: str, path_s: str):
"""Updates page number (-1 or +1).""" """Apply all staged changes globally."""
if 't5_page' not in st.session_state: SorterEngine.commit_global(path_o, cleanup_mode, operation, source_root=path_s)
st.session_state.t5_page = 0 StreamlitState.trigger_refresh()
st.session_state.t5_page += delta
def cb_jump_page(k): def action_add_category(name: str):
"""Updates page number from direct input box.""" """Add new category."""
val = st.session_state[k] if name:
st.session_state.t5_page = val - 1 SorterEngine.add_category(name)
st.session_state.t5_active_cat = name
def action_rename_category(old_name: str, new_name: str):
"""Rename category."""
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]
# ========================================== # ==========================================
# 2. FRAGMENT: SIDEBAR (Category Manager) # DIALOGS
# ========================================== # ==========================================
@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")
# --- PREPARE LIST (With Separators) --- # Get and process categories with separators
cats = SorterEngine.get_categories() cats = SorterEngine.get_categories() or ["Default"]
processed_cats = [] processed_cats = _add_category_separators(cats)
last_char = ""
if cats: # Sync radio selection immediately
for cat in cats: if "t5_radio_select" in st.session_state:
current_char = cat[0].upper() new_selection = st.session_state.t5_radio_select
if last_char and current_char != last_char: if not new_selection.startswith("---"):
processed_cats.append(f"--- {current_char} ---") st.session_state.t5_active_cat = new_selection
processed_cats.append(cat)
last_char = current_char
# --- STATE SYNC ---
if "t5_active_cat" not in st.session_state: if "t5_active_cat" not in st.session_state:
st.session_state.t5_active_cat = cats[0] if cats else "Default" st.session_state.t5_active_cat = cats[0]
# Fallback if selection was deleted current_cat = st.session_state.t5_active_cat
current_selection = st.session_state.t5_active_cat
if not current_selection.startswith("---") and current_selection not in cats: # NUMBER GRID (1-25) with previews
st.session_state.t5_active_cat = cats[0] if cats else "Default" if current_cat and not current_cat.startswith("---"):
st.caption(f"**{current_cat}** Index Map")
# --- RADIO SELECTION ---
selection = st.radio("Active Tag", processed_cats, key="t5_radio_select") # 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()
if not selection.startswith("---"):
st.session_state.t5_active_cat = selection
st.divider() st.divider()
# --- TABS: ADD / EDIT --- # 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()
st.rerun()
with tab_edit: with tab_edit:
target_cat = st.session_state.t5_active_cat if current_cat and not current_cat.startswith("---") and current_cat in cats:
is_valid = target_cat and not target_cat.startswith("---") and target_cat in cats st.caption(f"Editing: **{current_cat}**")
if is_valid:
st.caption(f"Editing: **{target_cat}**")
# RENAME rename_val = st.text_input(
rename_val = st.text_input("Rename to:", value=target_cat, key=f"ren_{target_cat}") "Rename to:",
if st.button("💾 Save Name", key=f"save_{target_cat}", use_container_width=True): value=current_cat,
if rename_val and rename_val != target_cat: key=f"ren_{current_cat}"
SorterEngine.rename_category(target_cat, rename_val) )
st.session_state.t5_active_cat = rename_val
st.rerun() if st.button("💾 Save", key=f"save_{current_cat}", use_container_width=True):
action_rename_category(current_cat, rename_val)
st.rerun()
st.markdown("---") st.markdown("---")
# DELETE if st.button(
if st.button("🗑️ Delete Category", key=f"del_cat_{target_cat}", type="primary", use_container_width=True): "🗑️ Delete Category",
SorterEngine.delete_category(target_cat) key=f"del_cat_{current_cat}",
type="primary",
use_container_width=True
):
action_delete_category(current_cat)
st.rerun() st.rerun()
else:
st.info("Select a valid category to edit.")
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
)
# ==========================================
# 3. FRAGMENT: GALLERY GRID
# ==========================================
@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()
selected_cat = st.session_state.get("t5_active_cat", "Default") history = SorterEngine.get_processed_log()
selected_cat = st.session_state.t5_active_cat
tagging_disabled = selected_cat.startswith("---") 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) 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]:
is_staged = img_path in staged _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")
with st.container(border=True): card_index = c_idx.number_input(
# Header "Index",
c_head1, c_head2 = st.columns([5, 1]) min_value=1,
c_head1.caption(os.path.basename(img_path)[:15]) step=1,
value=st.session_state.t5_next_index,
# DELETE (Callback) label_visibility="collapsed",
c_head2.button("", key=f"del_{unique_key}", key=f"idx_{unique_key}"
on_click=cb_delete_image, args=(img_path,)) )
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,)
)
# STATUS
if is_staged:
st.success(f"🏷️ {staged[img_path]['cat']}")
# IMAGE
img_data = SorterEngine.compress_for_web(img_path, quality)
if img_data:
st.image(img_data, use_container_width=True)
# ACTIONS (Callbacks)
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,))
# ==========================================
# 4. FRAGMENT: BATCH ACTIONS
# ==========================================
@st.fragment @st.fragment
def render_batch_actions(current_batch, path_o, page_num): def render_batch_actions(
st.write(f"### 🚀 Batch Actions (Page {page_num})") 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")
# We use columns to organize the settings clearly c_set1, c_set2 = st.columns(2)
c_set1, c_set2, c_btn = st.columns([2, 2, 1.5], vertical_alignment="bottom")
# 1. Operation for TAGGED files c_set1.radio(
op_mode = c_set1.radio("Tagged Files:", ["Move", "Copy"], "Tagged Files:",
horizontal=True, key="t5_op_mode") ["Copy", "Move"],
horizontal=True,
key="t5_op_mode"
)
# 2. Action for UNTAGGED files c_set2.radio(
cleanup = c_set2.radio("Untagged Files:", ["Keep", "Move to Unused", "Delete"], "Untagged Files:",
horizontal=True, key="t5_cleanup_mode") ["Keep", "Move to Unused", "Delete"],
horizontal=True,
key="t5_cleanup_mode"
)
# 3. Apply Button st.divider()
# Note: We added 'op_mode' to the args
if c_btn.button(f"APPLY ({op_mode})", type="primary", use_container_width=True, c_btn1, c_btn2 = st.columns(2)
on_click=cb_apply_batch, args=(current_batch, path_o, cleanup, op_mode)):
st.success("Batch processed!") # Apply Page button
# Rerun to show changes (files disappearing or remaining depending on copy/move) 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() st.rerun()
# ==========================================
# MAIN RENDER FUNCTION
# ==========================================
# ========================================== def render(quality: int, profile_name: str):
# 5. MAIN RENDERER """Main render function for Streamlit app."""
# ==========================================
def render(quality, profile_name):
st.subheader("🖼️ Gallery Staging Sorter") st.subheader("🖼️ Gallery Staging Sorter")
# Init State # Initialize state
if 't5_page' not in st.session_state: st.session_state.t5_page = 0 StreamlitState.init()
# Load Paths # 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) 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")
if path_s != p_data.get("tab5_source") or path_o != p_data.get("tab5_out"): path_s = c1.text_input(
if st.button("💾 Save Settings"): "Source Folder",
SorterEngine.save_tab_paths(profile_name, t5_s=path_s, t5_o=path_o) value=p_data.get("tab5_source", "/storage"),
st.rerun() key="t5_s"
)
if not os.path.exists(path_s): return
path_o = c2.text_input(
# --- RENDER SIDEBAR --- "Output Folder",
with st.sidebar: value=p_data.get("tab5_out", "/storage"),
render_sidebar_content() key="t5_o"
)
# --- VIEW SETTINGS ---
with st.expander("👀 View Settings"): # Save settings button
c_v1, c_v2 = st.columns(2) if c3.button("💾 Save", use_container_width=True):
page_size = c_v1.slider("Images per Page", 12, 100, 24, 4) SorterEngine.save_tab_paths(profile_name, t5_s=path_s, t5_o=path_o)
grid_cols = c_v2.slider("Grid Columns", 2, 8, 4) StreamlitState.trigger_refresh()
st.toast("Settings saved!")
# --- DATA & MATH --- st.rerun()
all_images = SorterEngine.get_images(path_s, recursive=True)
if not all_images: # Validate source path
st.info("No images found.") if not os.path.exists(path_s):
st.warning("⚠️ Source path does not exist")
return return
total_items = len(all_images)
total_pages = math.ceil(total_items / page_size)
# Safety Bounds Check
if st.session_state.t5_page >= total_pages: st.session_state.t5_page = max(0, total_pages - 1)
if st.session_state.t5_page < 0: st.session_state.t5_page = 0
start_idx = st.session_state.t5_page * page_size # Render sidebar
end_idx = start_idx + page_size with st.sidebar:
current_batch = all_images[start_idx:end_idx] render_sidebar_content(path_o)
# --- NAVIGATION BAR COMPONENT --- # View settings
def nav_controls(key_suffix): with st.expander("👀 View Settings", expanded=False):
# New Layout: [Prev] [Input Box] ["/ 15"] [Next] c_v1, c_v2, c_v3 = st.columns(3)
c1, c2, c3, c4 = st.columns([1.5, 1, 0.5, 1.5], vertical_alignment="center")
# 1. Previous Button st.session_state.t5_page_size = c_v1.slider(
c1.button("⬅️ Prev", "Images/Page",
disabled=(st.session_state.t5_page == 0), 12, 100,
on_click=cb_change_page, args=(-1,), st.session_state.t5_page_size,
key=f"p_{key_suffix}", use_container_width=True) 4
# 2. Page Selector (Number Input)
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}",)
) )
# 3. Total Page Count Display st.session_state.t5_grid_cols = c_v2.slider(
c3.markdown(f"<div style='text-align: left; font-weight: bold;'>/ {total_pages}</div>", unsafe_allow_html=True) "Grid Columns",
2, 8,
st.session_state.t5_grid_cols
)
# 4. Next Button st.session_state.t5_quality = c_v3.slider(
c4.button("Next ➡️", "Preview Quality",
disabled=(st.session_state.t5_page >= total_pages - 1), 10, 100,
on_click=cb_change_page, args=(1,), st.session_state.t5_quality,
key=f"n_{key_suffix}", use_container_width=True) 10
)
# --- RENDER PAGE ---
st.divider()
nav_controls("top") # Top Nav
render_gallery_grid(current_batch, quality, grid_cols) # Grid # Load images (cached)
all_images = get_cached_images(path_s, st.session_state.t5_file_id)
if not all_images:
st.info("📂 No images found in source folder")
return
# Pagination calculations
page_size = st.session_state.t5_page_size
total_pages = math.ceil(len(all_images) / page_size)
# Bounds checking
if st.session_state.t5_page >= total_pages:
st.session_state.t5_page = max(0, total_pages - 1)
if st.session_state.t5_page < 0:
st.session_state.t5_page = 0
current_page = st.session_state.t5_page
start_idx = current_page * page_size
current_batch = all_images[start_idx : start_idx + page_size]
# Calculate green dots (cached)
staged = SorterEngine.get_staged_data()
green_dots = get_cached_green_dots(
all_images,
page_size,
frozenset(staged.keys())
)
# Render UI components
st.divider()
# 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() st.divider()
nav_controls("bottom") # Bottom Nav
# Bottom pagination
render_pagination_carousel("bot", total_pages, current_page, green_dots)
st.divider() st.divider()
# Batch Actions # Batch actions
render_batch_actions(current_batch, path_o, st.session_state.t5_page + 1) render_batch_actions(current_batch, path_o, current_page + 1, path_s)