This commit is contained in:
2026-01-28 15:42:14 +01:00
parent 7580036c9d
commit bf1134e47f
5 changed files with 269 additions and 116 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.

View File

@@ -1,12 +1,28 @@
import os import os
import shutil import shutil
import sqlite3 import sqlite3
from contextlib import contextmanager
from PIL import Image from PIL import Image
from io import BytesIO from io import BytesIO
class SorterEngine: class SorterEngine:
DB_PATH = "/app/sorter_database.db" DB_PATH = "/app/sorter_database.db"
@staticmethod
@contextmanager
def get_db():
"""Context manager for database connections.
Ensures proper commit/rollback and always closes connection."""
conn = sqlite3.connect(SorterEngine.DB_PATH)
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
# --- 1. DATABASE INITIALIZATION --- # --- 1. DATABASE INITIALIZATION ---
@staticmethod @staticmethod
def init_db(): def init_db():
@@ -52,6 +68,14 @@ class SorterEngine:
for cat in ["_TRASH", "control", "Default", "Action", "Solo"]: for cat in ["_TRASH", "control", "Default", "Action", "Solo"]:
cursor.execute("INSERT OR IGNORE INTO categories VALUES (?)", (cat,)) cursor.execute("INSERT OR IGNORE INTO categories VALUES (?)", (cat,))
# --- PERFORMANCE INDEXES ---
# Index for staging_area queries filtered by category
cursor.execute("CREATE INDEX IF NOT EXISTS idx_staging_category ON staging_area(target_category)")
# Index for folder_tags queries filtered by profile and folder_path
cursor.execute("CREATE INDEX IF NOT EXISTS idx_folder_tags_profile ON folder_tags(profile, folder_path)")
# Index for profile_categories lookups
cursor.execute("CREATE INDEX IF NOT EXISTS idx_profile_categories ON profile_categories(profile)")
conn.commit() conn.commit()
conn.close() conn.close()
@@ -146,13 +170,12 @@ class SorterEngine:
@staticmethod @staticmethod
def load_profiles(): def load_profiles():
"""Loads all workspace presets including pairing settings.""" """Loads all workspace presets including pairing settings.
Uses LEFT JOIN to fetch all data in a single query (fixes N+1 problem)."""
conn = sqlite3.connect(SorterEngine.DB_PATH) conn = sqlite3.connect(SorterEngine.DB_PATH)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT * FROM profiles")
rows = cursor.fetchall()
# Ensure pairing_settings table exists # Ensure pairing_settings table exists before JOIN
cursor.execute('''CREATE TABLE IF NOT EXISTS pairing_settings cursor.execute('''CREATE TABLE IF NOT EXISTS pairing_settings
(profile TEXT PRIMARY KEY, (profile TEXT PRIMARY KEY,
adjacent_folder TEXT, adjacent_folder TEXT,
@@ -162,26 +185,33 @@ class SorterEngine:
adj_output TEXT, adj_output TEXT,
time_window INTEGER)''') time_window INTEGER)''')
# Single query with LEFT JOIN - eliminates N+1 queries
cursor.execute('''
SELECT p.name, p.tab1_target, p.tab2_target, p.tab2_control,
p.tab4_source, p.tab4_out, p.mode, p.tab5_source, p.tab5_out,
ps.adjacent_folder, ps.main_category, ps.adj_category,
ps.main_output, ps.adj_output, ps.time_window
FROM profiles p
LEFT JOIN pairing_settings ps ON p.name = ps.profile
''')
rows = cursor.fetchall()
profiles = {} profiles = {}
for r in rows: for r in rows:
profile_name = r[0] profile_name = r[0]
profiles[profile_name] = { profiles[profile_name] = {
"tab1_target": r[1], "tab2_target": r[2], "tab2_control": r[3], "tab1_target": r[1], "tab2_target": r[2], "tab2_control": r[3],
"tab4_source": r[4], "tab4_out": r[5], "mode": r[6], "tab4_source": r[4], "tab4_out": r[5], "mode": r[6],
"tab5_source": r[7], "tab5_out": r[8] "tab5_source": r[7], "tab5_out": r[8],
# Pairing settings from JOIN (with defaults for NULL)
"pair_adjacent_folder": r[9] or "",
"pair_main_category": r[10] or "control",
"pair_adj_category": r[11] or "control",
"pair_main_output": r[12] or "/storage",
"pair_adj_output": r[13] or "/storage",
"pair_time_window": r[14] or 60
} }
# Load pairing settings for this profile
cursor.execute("SELECT * FROM pairing_settings WHERE profile = ?", (profile_name,))
pair_row = cursor.fetchone()
if pair_row:
profiles[profile_name]["pair_adjacent_folder"] = pair_row[1] or ""
profiles[profile_name]["pair_main_category"] = pair_row[2] or "control"
profiles[profile_name]["pair_adj_category"] = pair_row[3] or "control"
profiles[profile_name]["pair_main_output"] = pair_row[4] or "/storage"
profiles[profile_name]["pair_adj_output"] = pair_row[5] or "/storage"
profiles[profile_name]["pair_time_window"] = pair_row[6] or 60
conn.close() conn.close()
return profiles return profiles
@@ -354,40 +384,33 @@ class SorterEngine:
@staticmethod @staticmethod
def stage_image(original_path, category, new_name): def stage_image(original_path, category, new_name):
"""Records a pending rename/move in the database.""" """Records a pending rename/move in the database."""
conn = sqlite3.connect(SorterEngine.DB_PATH) with SorterEngine.get_db() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("INSERT OR REPLACE INTO staging_area VALUES (?, ?, ?, 1)", (original_path, category, new_name)) cursor.execute("INSERT OR REPLACE INTO staging_area VALUES (?, ?, ?, 1)", (original_path, category, new_name))
conn.commit()
conn.close()
@staticmethod @staticmethod
def clear_staged_item(original_path): def clear_staged_item(original_path):
"""Removes an item from the pending staging area.""" """Removes an item from the pending staging area."""
conn = sqlite3.connect(SorterEngine.DB_PATH) with SorterEngine.get_db() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("DELETE FROM staging_area WHERE original_path = ?", (original_path,)) cursor.execute("DELETE FROM staging_area WHERE original_path = ?", (original_path,))
conn.commit()
conn.close()
@staticmethod @staticmethod
def clear_staging_area(): def clear_staging_area():
"""Clears all items from the staging area.""" """Clears all items from the staging area."""
conn = sqlite3.connect(SorterEngine.DB_PATH) with SorterEngine.get_db() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("DELETE FROM staging_area") cursor.execute("DELETE FROM staging_area")
conn.commit()
conn.close()
@staticmethod @staticmethod
def get_staged_data(): def get_staged_data():
"""Retrieves current tagged/staged images.""" """Retrieves current tagged/staged images."""
conn = sqlite3.connect(SorterEngine.DB_PATH) with SorterEngine.get_db() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT * FROM staging_area") cursor.execute("SELECT * FROM staging_area")
rows = cursor.fetchall() rows = cursor.fetchall()
conn.close() # FIXED: Added "marked": r[3] to the dictionary
# FIXED: Added "marked": r[3] to the dictionary return {r[0]: {"cat": r[1], "name": r[2], "marked": r[3]} for r in rows}
return {r[0]: {"cat": r[1], "name": r[2], "marked": r[3]} for r in rows}
@staticmethod @staticmethod
def commit_global(output_root, cleanup_mode, operation="Copy", source_root=None, profile=None): def commit_global(output_root, cleanup_mode, operation="Copy", source_root=None, profile=None):

View File

@@ -1,7 +1,8 @@
import os import os
import math import math
import asyncio import asyncio
from typing import Optional, List, Dict, Set from typing import Optional, List, Dict, Set, Tuple
from functools import partial
from nicegui import ui, app, run from nicegui import ui, app, run
from fastapi import Response from fastapi import Response
from engine import SorterEngine from engine import SorterEngine
@@ -49,6 +50,12 @@ class AppState:
self.green_dots: Set[int] = set() self.green_dots: Set[int] = set()
self.index_map: Dict[int, str] = {} self.index_map: Dict[int, str] = {}
# Performance caches (Phase 1 optimizations)
self._cached_tagged_count: int = 0 # Cached count for get_stats()
self._green_dots_dirty: bool = True # Lazy green dots calculation
self._last_disk_scan_key: str = "" # Track output_dir + category for lazy disk scan
self._disk_index_map: Dict[int, str] = {} # Cached disk scan results
# UI Containers (populated later) # UI Containers (populated later)
self.sidebar_container = None self.sidebar_container = None
self.grid_container = None self.grid_container = None
@@ -59,7 +66,7 @@ class AppState:
self.pair_time_window = 60 # seconds +/- for matching self.pair_time_window = 60 # seconds +/- for matching
self.pair_current_idx = 0 # Current image index in pairing mode self.pair_current_idx = 0 # Current image index in pairing mode
self.pair_adjacent_folder = "" # Path to adjacent folder self.pair_adjacent_folder = "" # Path to adjacent folder
self.pair_adjacent_images: List[str] = [] # Images from adjacent folder self.pair_adjacent_data: List[Tuple[str, float]] = [] # (path, timestamp) tuples for O(1) lookup
self.pair_matches: List[str] = [] # Current matches for selected image self.pair_matches: List[str] = [] # Current matches for selected image
self.pair_selected_match = None # Currently selected match self.pair_selected_match = None # Currently selected match
self.pairing_container = None # UI container for pairing mode self.pairing_container = None # UI container for pairing mode
@@ -165,11 +172,23 @@ class AppState:
return filtered[start : start + self.page_size] return filtered[start : start + self.page_size]
def get_stats(self) -> Dict: def get_stats(self) -> Dict:
"""Get image statistics for display.""" """Get image statistics for display. Uses cached tagged count."""
total = len(self.all_images) total = len(self.all_images)
tagged = len([img for img in self.all_images if img in self.staged_data]) tagged = self._cached_tagged_count
return {"total": total, "tagged": tagged, "untagged": total - tagged} return {"total": total, "tagged": tagged, "untagged": total - tagged}
def get_green_dots(self) -> Set[int]:
"""Lazily calculate green dots (pages with tagged images).
Only recalculates when _green_dots_dirty is True."""
if self._green_dots_dirty:
self.green_dots.clear()
staged_keys = set(self.staged_data.keys())
for idx, img_path in enumerate(self.all_images):
if img_path in staged_keys:
self.green_dots.add(idx // self.page_size)
self._green_dots_dirty = False
return self.green_dots
state = AppState() state = AppState()
# ========================================== # ==========================================
@@ -237,35 +256,45 @@ def get_file_timestamp(filepath: str) -> Optional[float]:
return None return None
def load_adjacent_folder(): def load_adjacent_folder():
"""Load images from adjacent folder for pairing, excluding main folder.""" """Load images from adjacent folder for pairing, excluding main folder.
Caches timestamps at load time to avoid repeated syscalls during navigation."""
if not state.pair_adjacent_folder or not os.path.exists(state.pair_adjacent_folder): if not state.pair_adjacent_folder or not os.path.exists(state.pair_adjacent_folder):
state.pair_adjacent_images = [] state.pair_adjacent_data = []
ui.notify("Adjacent folder path is empty or doesn't exist", type='warning') ui.notify("Adjacent folder path is empty or doesn't exist", type='warning')
return return
# Exclude the main source folder to avoid duplicates # Exclude the main source folder to avoid duplicates
exclude = [state.source_dir] if state.source_dir else [] exclude = [state.source_dir] if state.source_dir else []
state.pair_adjacent_images = SorterEngine.get_images( images = SorterEngine.get_images(
state.pair_adjacent_folder, state.pair_adjacent_folder,
recursive=True, recursive=True,
exclude_paths=exclude exclude_paths=exclude
) )
ui.notify(f"Loaded {len(state.pair_adjacent_images)} images from adjacent folder", type='info')
# Cache timestamps at load time (one-time cost instead of per-navigation)
state.pair_adjacent_data = []
for img_path in images:
ts = get_file_timestamp(img_path)
if ts is not None:
state.pair_adjacent_data.append((img_path, ts))
ui.notify(f"Loaded {len(state.pair_adjacent_data)} images from adjacent folder", type='info')
def find_time_matches(source_image: str) -> List[str]: def find_time_matches(source_image: str) -> List[str]:
"""Find images in adjacent folder within time window of source image.""" """Find images in adjacent folder within time window of source image.
Uses cached timestamps from pair_adjacent_data for O(n) without syscalls."""
source_time = get_file_timestamp(source_image) source_time = get_file_timestamp(source_image)
if source_time is None: if source_time is None:
return [] return []
window = state.pair_time_window
matches = [] matches = []
for adj_image in state.pair_adjacent_images: # Use pre-cached timestamps - no syscalls needed
adj_time = get_file_timestamp(adj_image) for adj_path, adj_time in state.pair_adjacent_data:
if adj_time is not None: time_diff = abs(source_time - adj_time)
time_diff = abs(source_time - adj_time) if time_diff <= window:
if time_diff <= state.pair_time_window: matches.append((adj_path, time_diff))
matches.append((adj_image, time_diff))
# Sort by time difference (closest first) # Sort by time difference (closest first)
matches.sort(key=lambda x: x[1]) matches.sort(key=lambda x: x[1])
@@ -459,16 +488,21 @@ def select_match(match_path: str):
state.pair_selected_match = match_path state.pair_selected_match = match_path
render_pairing_view() render_pairing_view()
def refresh_staged_info(): def refresh_staged_info(force_disk_scan: bool = False):
"""Update staged data and index maps.""" """Update staged data and index maps.
state.staged_data = SorterEngine.get_staged_data()
# Update green dots (pages with staged images) Args:
state.green_dots.clear() force_disk_scan: If True, rescan disk even if category hasn't changed.
Set this after APPLY operations that modify files.
"""
state.staged_data = SorterEngine.get_staged_data()
staged_keys = set(state.staged_data.keys()) staged_keys = set(state.staged_data.keys())
for idx, img_path in enumerate(state.all_images):
if img_path in staged_keys: # Update cached tagged count (O(n) but simpler than set intersection)
state.green_dots.add(idx // state.page_size) state._cached_tagged_count = sum(1 for img in state.all_images if img in staged_keys)
# Mark green dots as dirty (lazy calculation)
state._green_dots_dirty = True
# Build index map for active category (gallery mode) # Build index map for active category (gallery mode)
state.index_map.clear() state.index_map.clear()
@@ -480,14 +514,24 @@ def refresh_staged_info():
if idx is not None: if idx is not None:
state.index_map[idx] = orig_path state.index_map[idx] = orig_path
# Add committed images from disk # Lazy disk scan: only rescan when output_dir+category changes or forced
cat_path = os.path.join(state.output_dir, state.active_cat) disk_scan_key = f"{state.output_dir}:{state.active_cat}"
if os.path.exists(cat_path): cache_valid = state._last_disk_scan_key == disk_scan_key
for filename in os.listdir(cat_path): if not cache_valid or force_disk_scan:
if filename.startswith(state.active_cat): state._last_disk_scan_key = disk_scan_key
idx = _extract_index(filename) state._disk_index_map.clear()
if idx is not None and idx not in state.index_map: cat_path = os.path.join(state.output_dir, state.active_cat)
state.index_map[idx] = os.path.join(cat_path, filename) if os.path.exists(cat_path):
for filename in os.listdir(cat_path):
if filename.startswith(state.active_cat):
idx = _extract_index(filename)
if idx is not None:
state._disk_index_map[idx] = os.path.join(cat_path, filename)
# Merge disk results into index_map (staged takes precedence)
for idx, path in state._disk_index_map.items():
if idx not in state.index_map:
state.index_map[idx] = path
# Build pairing mode index map (both categories) # Build pairing mode index map (both categories)
state.pair_index_map.clear() state.pair_index_map.clear()
@@ -549,7 +593,9 @@ def action_tag(img_path: str, manual_idx: Optional[int] = None):
state.next_index = idx + 1 state.next_index = idx + 1
refresh_staged_info() refresh_staged_info()
refresh_ui() # Use targeted refresh - sidebar index grid needs update, but skip heavy rebuild
render_sidebar() # Update index grid to show new tag
refresh_grid_only() # Just grid + pagination stats
def action_untag(img_path: str): def action_untag(img_path: str):
"""Remove staging from an image.""" """Remove staging from an image."""
@@ -568,7 +614,9 @@ def action_untag(img_path: str):
SorterEngine.clear_staged_item(img_path) SorterEngine.clear_staged_item(img_path)
refresh_staged_info() refresh_staged_info()
refresh_ui() # Use targeted refresh - sidebar index grid needs update
render_sidebar() # Update index grid to show removed tag
refresh_grid_only() # Just grid + pagination stats
def action_delete(img_path: str): def action_delete(img_path: str):
"""Delete image to trash.""" """Delete image to trash."""
@@ -635,6 +683,8 @@ def action_apply_page():
SorterEngine.commit_batch(batch, state.output_dir, state.cleanup_mode, state.batch_mode) SorterEngine.commit_batch(batch, state.output_dir, state.cleanup_mode, state.batch_mode)
ui.notify(f"Page processed ({state.batch_mode})", type='positive') ui.notify(f"Page processed ({state.batch_mode})", type='positive')
# Force disk rescan since files were committed
state._last_disk_scan_key = ""
load_images() load_images()
async def action_apply_global(): async def action_apply_global():
@@ -648,6 +698,8 @@ async def action_apply_global():
state.source_dir, state.source_dir,
state.profile_name state.profile_name
) )
# Force disk rescan since files were committed
state._last_disk_scan_key = ""
load_images() load_images()
ui.notify("Global apply complete!", type='positive') ui.notify("Global apply complete!", type='positive')
@@ -991,17 +1043,26 @@ def render_gallery():
for img_path in batch: for img_path in batch:
render_image_card(img_path) render_image_card(img_path)
def _set_hovered(path: str):
"""Helper for hover tracking - used with partial for memory efficiency."""
state.hovered_image = path
def _clear_hovered():
"""Helper for hover tracking - used with partial for memory efficiency."""
state.hovered_image = None
def render_image_card(img_path: str): def render_image_card(img_path: str):
"""Render individual image card.""" """Render individual image card.
Uses functools.partial instead of lambdas for better memory efficiency."""
is_staged = img_path in state.staged_data is_staged = img_path in state.staged_data
thumb_size = 800 thumb_size = 800
card = ui.card().classes('p-2 bg-gray-900 border border-gray-700 no-shadow hover:border-green-500 transition-colors') card = ui.card().classes('p-2 bg-gray-900 border border-gray-700 no-shadow hover:border-green-500 transition-colors')
with card: with card:
# Track hover for keyboard shortcuts # Track hover for keyboard shortcuts - using partial instead of lambda
card.on('mouseenter', lambda p=img_path: setattr(state, 'hovered_image', p)) card.on('mouseenter', partial(_set_hovered, img_path))
card.on('mouseleave', lambda: setattr(state, 'hovered_image', None)) card.on('mouseleave', _clear_hovered)
# Header with filename and actions # Header with filename and actions
with ui.row().classes('w-full justify-between no-wrap mb-1'): with ui.row().classes('w-full justify-between no-wrap mb-1'):
@@ -1009,11 +1070,11 @@ def render_image_card(img_path: str):
with ui.row().classes('gap-0'): with ui.row().classes('gap-0'):
ui.button( ui.button(
icon='zoom_in', icon='zoom_in',
on_click=lambda p=img_path: open_zoom_dialog(p) on_click=partial(open_zoom_dialog, img_path)
).props('flat size=sm dense color=white') ).props('flat size=sm dense color=white')
ui.button( ui.button(
icon='delete', icon='delete',
on_click=lambda p=img_path: action_delete(p) on_click=partial(action_delete, img_path)
).props('flat size=sm dense color=red') ).props('flat size=sm dense color=red')
# Thumbnail with double-click to tag # Thumbnail with double-click to tag
@@ -1021,11 +1082,11 @@ def render_image_card(img_path: str):
.classes('w-full h-64 bg-black rounded cursor-pointer') \ .classes('w-full h-64 bg-black rounded cursor-pointer') \
.props('fit=contain no-spinner') .props('fit=contain no-spinner')
# Double-click to tag (if not already tagged) # Double-click to tag (if not already tagged) - using partial
if not is_staged: if not is_staged:
img.on('dblclick', lambda p=img_path: action_tag(p)) img.on('dblclick', partial(action_tag, img_path))
else: else:
img.on('dblclick', lambda p=img_path: action_untag(p)) img.on('dblclick', partial(action_untag, img_path))
# Tagging UI # Tagging UI
if is_staged: if is_staged:
@@ -1035,12 +1096,13 @@ def render_image_card(img_path: str):
ui.label(f"🏷️ {info['cat']}").classes('text-center text-green-400 text-xs py-1 w-full') ui.label(f"🏷️ {info['cat']}").classes('text-center text-green-400 text-xs py-1 w-full')
ui.button( ui.button(
f"Untag (#{idx_str})", f"Untag (#{idx_str})",
on_click=lambda p=img_path: action_untag(p) on_click=partial(action_untag, img_path)
).props('flat color=grey-5 dense').classes('w-full') ).props('flat color=grey-5 dense').classes('w-full')
else: else:
with ui.row().classes('w-full no-wrap mt-2 gap-1'): with ui.row().classes('w-full no-wrap mt-2 gap-1'):
local_idx = ui.number(value=state.next_index, precision=0) \ local_idx = ui.number(value=state.next_index, precision=0) \
.props('dense dark outlined').classes('w-1/3') .props('dense dark outlined').classes('w-1/3')
# Note: This one still needs lambda due to dynamic local_idx.value access
ui.button( ui.button(
'Tag', 'Tag',
on_click=lambda p=img_path, i=local_idx: action_tag(p, int(i.value)) on_click=lambda p=img_path, i=local_idx: action_tag(p, int(i.value))
@@ -1108,8 +1170,9 @@ def render_pagination():
start = max(0, state.page - 2) start = max(0, state.page - 2)
end = min(state.total_pages, state.page + 3) end = min(state.total_pages, state.page + 3)
green_dots = state.get_green_dots() # Lazy calculation
for p in range(start, end): for p in range(start, end):
dot = " 🟢" if p in state.green_dots else "" dot = " 🟢" if p in green_dots else ""
color = "white" if p == state.page else "grey-6" color = "white" if p == state.page else "grey-6"
ui.button( ui.button(
f"{p+1}{dot}", f"{p+1}{dot}",
@@ -1131,6 +1194,12 @@ def refresh_ui():
render_pagination() render_pagination()
render_gallery() render_gallery()
def refresh_grid_only():
"""Refresh only the grid and pagination stats - skip sidebar rebuild.
Use for tag/untag operations where sidebar doesn't need full rebuild."""
render_pagination()
render_gallery()
def handle_keyboard(e): def handle_keyboard(e):
"""Handle keyboard navigation and shortcuts (fallback).""" """Handle keyboard navigation and shortcuts (fallback)."""
if not e.action.keydown: if not e.action.keydown: