Compare commits
10 Commits
d30414d972
...
28e8722a10
| Author | SHA1 | Date | |
|---|---|---|---|
| 28e8722a10 | |||
| b909069174 | |||
| 9c86eb4b72 | |||
| 4636a79ada | |||
| ff27a3bc83 | |||
| 7eb71cab56 | |||
| 758125a60b | |||
| e7144eb6cf | |||
| 8328e4d3b4 | |||
| 6363ea4590 |
69
engine.py
69
engine.py
@@ -253,8 +253,8 @@ class SorterEngine:
|
|||||||
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="Move", source_root=None):
|
def commit_global(output_root, cleanup_mode, operation="Copy", source_root=None):
|
||||||
"""Commits ALL staged files (Global Apply)."""
|
"""Commits ALL staged files and fixes permissions."""
|
||||||
data = SorterEngine.get_staged_data()
|
data = SorterEngine.get_staged_data()
|
||||||
conn = sqlite3.connect(SorterEngine.DB_PATH)
|
conn = sqlite3.connect(SorterEngine.DB_PATH)
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
@@ -278,20 +278,26 @@ class SorterEngine:
|
|||||||
else:
|
else:
|
||||||
shutil.move(old_p, final_dst)
|
shutil.move(old_p, final_dst)
|
||||||
|
|
||||||
|
# --- FIX PERMISSIONS ---
|
||||||
|
SorterEngine.fix_permissions(final_dst)
|
||||||
|
|
||||||
# Log History
|
# Log History
|
||||||
cursor.execute("INSERT OR REPLACE INTO processed_log VALUES (?, ?, ?)",
|
cursor.execute("INSERT OR REPLACE INTO processed_log VALUES (?, ?, ?)",
|
||||||
(old_p, info['cat'], operation))
|
(old_p, info['cat'], operation))
|
||||||
|
|
||||||
# 2. Global Cleanup (Optional)
|
# 2. Global Cleanup
|
||||||
# Only run cleanup if explicitly asked, as global cleanup is risky
|
|
||||||
if cleanup_mode != "Keep" and source_root:
|
if cleanup_mode != "Keep" and source_root:
|
||||||
all_imgs = SorterEngine.get_images(source_root, recursive=True)
|
all_imgs = SorterEngine.get_images(source_root, recursive=True)
|
||||||
for img_p in all_imgs:
|
for img_p in all_imgs:
|
||||||
if img_p not in data: # Not currently staged
|
if img_p not in data:
|
||||||
if cleanup_mode == "Move to Unused":
|
if cleanup_mode == "Move to Unused":
|
||||||
unused_dir = os.path.join(source_root, "unused")
|
unused_dir = os.path.join(source_root, "unused")
|
||||||
os.makedirs(unused_dir, exist_ok=True)
|
os.makedirs(unused_dir, exist_ok=True)
|
||||||
shutil.move(img_p, os.path.join(unused_dir, os.path.basename(img_p)))
|
dest_unused = os.path.join(unused_dir, os.path.basename(img_p))
|
||||||
|
|
||||||
|
shutil.move(img_p, dest_unused)
|
||||||
|
SorterEngine.fix_permissions(dest_unused)
|
||||||
|
|
||||||
elif cleanup_mode == "Delete":
|
elif cleanup_mode == "Delete":
|
||||||
os.remove(img_p)
|
os.remove(img_p)
|
||||||
|
|
||||||
@@ -354,14 +360,29 @@ class SorterEngine:
|
|||||||
return t_dst, c_dst
|
return t_dst, c_dst
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def compress_for_web(path, quality):
|
def compress_for_web(path, quality, target_size=None):
|
||||||
"""Compresses images for UI performance."""
|
"""
|
||||||
|
Loads image, resizes smart, and saves as WebP.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
with Image.open(path) as img:
|
with Image.open(path) as img:
|
||||||
|
# 1. Convert to RGB (WebP handles RGBA, but RGB is safer for consistency)
|
||||||
|
if img.mode not in ('RGB', 'RGBA'):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
|
||||||
|
# 2. Smart Resize (Only if target_size is provided)
|
||||||
|
if target_size:
|
||||||
|
# Only resize if the original is actually bigger
|
||||||
|
if img.width > target_size or img.height > target_size:
|
||||||
|
img.thumbnail((target_size, target_size), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
# 3. Save as WebP
|
||||||
buf = BytesIO()
|
buf = BytesIO()
|
||||||
img.convert("RGB").save(buf, format="JPEG", quality=quality)
|
# WebP is faster to decode in browser and smaller on disk
|
||||||
return buf
|
img.save(buf, format="WEBP", quality=quality)
|
||||||
except: return None
|
return buf.getvalue()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def revert_action(action):
|
def revert_action(action):
|
||||||
@@ -384,8 +405,17 @@ class SorterEngine:
|
|||||||
return {r[0]: {"cat": r[1], "action": r[2]} for r in rows}
|
return {r[0]: {"cat": r[1], "action": r[2]} for r in rows}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def commit_batch(file_list, output_root, cleanup_mode, operation="Move"):
|
def fix_permissions(path):
|
||||||
"""Commits specified files and LOGS them to history."""
|
"""Forces file to be fully accessible (rwxrwxrwx)."""
|
||||||
|
try:
|
||||||
|
# 0o777 gives Read, Write, and Execute access to Owner, Group, and Others.
|
||||||
|
os.chmod(path, 0o777)
|
||||||
|
except Exception:
|
||||||
|
pass # Ignore errors if OS doesn't support chmod (e.g. some Windows setups)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def commit_batch(file_list, output_root, cleanup_mode, operation="Copy"):
|
||||||
|
"""Commits files and fixes permissions."""
|
||||||
data = SorterEngine.get_staged_data()
|
data = SorterEngine.get_staged_data()
|
||||||
conn = sqlite3.connect(SorterEngine.DB_PATH)
|
conn = sqlite3.connect(SorterEngine.DB_PATH)
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
@@ -408,13 +438,16 @@ class SorterEngine:
|
|||||||
final_dst = os.path.join(output_root, f"{root}_{c}{ext}")
|
final_dst = os.path.join(output_root, f"{root}_{c}{ext}")
|
||||||
c += 1
|
c += 1
|
||||||
|
|
||||||
# Action
|
# Perform Action
|
||||||
if operation == "Copy":
|
if operation == "Copy":
|
||||||
shutil.copy2(file_path, final_dst)
|
shutil.copy2(file_path, final_dst)
|
||||||
else:
|
else:
|
||||||
shutil.move(file_path, final_dst)
|
shutil.move(file_path, final_dst)
|
||||||
|
|
||||||
# Update DB: Remove from Staging, Add to History
|
# --- FIX PERMISSIONS ---
|
||||||
|
SorterEngine.fix_permissions(final_dst)
|
||||||
|
|
||||||
|
# Update DB
|
||||||
cursor.execute("DELETE FROM staging_area WHERE original_path = ?", (file_path,))
|
cursor.execute("DELETE FROM staging_area WHERE original_path = ?", (file_path,))
|
||||||
cursor.execute("INSERT OR REPLACE INTO processed_log VALUES (?, ?, ?)",
|
cursor.execute("INSERT OR REPLACE INTO processed_log VALUES (?, ?, ?)",
|
||||||
(file_path, info['cat'], operation))
|
(file_path, info['cat'], operation))
|
||||||
@@ -424,7 +457,11 @@ class SorterEngine:
|
|||||||
if cleanup_mode == "Move to Unused":
|
if cleanup_mode == "Move to Unused":
|
||||||
unused_dir = os.path.join(os.path.dirname(file_path), "unused")
|
unused_dir = os.path.join(os.path.dirname(file_path), "unused")
|
||||||
os.makedirs(unused_dir, exist_ok=True)
|
os.makedirs(unused_dir, exist_ok=True)
|
||||||
shutil.move(file_path, os.path.join(unused_dir, os.path.basename(file_path)))
|
dest_unused = os.path.join(unused_dir, os.path.basename(file_path))
|
||||||
|
|
||||||
|
shutil.move(file_path, dest_unused)
|
||||||
|
SorterEngine.fix_permissions(dest_unused) # Fix here too
|
||||||
|
|
||||||
elif cleanup_mode == "Delete":
|
elif cleanup_mode == "Delete":
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
|
|
||||||
|
|||||||
@@ -23,16 +23,46 @@ def trigger_refresh():
|
|||||||
if 't5_file_id' not in st.session_state: st.session_state.t5_file_id = 0
|
if 't5_file_id' not in st.session_state: st.session_state.t5_file_id = 0
|
||||||
st.session_state.t5_file_id += 1
|
st.session_state.t5_file_id += 1
|
||||||
|
|
||||||
def cb_tag_image(img_path, selected_cat):
|
def cb_tag_image(img_path, selected_cat, index_val, path_o):
|
||||||
|
"""
|
||||||
|
Tags image with manual number.
|
||||||
|
Handles collisions by creating variants (e.g. _001_1) and warning the user.
|
||||||
|
"""
|
||||||
if selected_cat.startswith("---") or selected_cat == "":
|
if selected_cat.startswith("---") or 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]
|
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 ---
|
||||||
|
# 1. Check Staging DB
|
||||||
|
staged = SorterEngine.get_staged_data()
|
||||||
|
# Get all names currently staged for this category
|
||||||
|
staged_names = {v['name'] for v in staged.values() if v['cat'] == selected_cat}
|
||||||
|
|
||||||
|
# 2. Check Hard Drive
|
||||||
|
dest_path = os.path.join(path_o, selected_cat, new_name)
|
||||||
|
|
||||||
|
collision = False
|
||||||
|
suffix = 1
|
||||||
|
|
||||||
|
# Loop until we find a free name
|
||||||
|
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
|
||||||
|
|
||||||
|
# --- SAVE ---
|
||||||
SorterEngine.stage_image(img_path, selected_cat, new_name)
|
SorterEngine.stage_image(img_path, selected_cat, new_name)
|
||||||
# Note: Tagging does NOT need a file re-scan, just a grid refresh.
|
|
||||||
|
if collision:
|
||||||
|
st.toast(f"⚠️ Conflict! Saved as variant: {new_name}", icon="🔀")
|
||||||
|
|
||||||
|
# REMOVED: st.session_state.t5_next_index += 1
|
||||||
|
# The numbers in the input boxes will now stay static.
|
||||||
|
|
||||||
def cb_untag_image(img_path):
|
def cb_untag_image(img_path):
|
||||||
SorterEngine.clear_staged_item(img_path)
|
SorterEngine.clear_staged_item(img_path)
|
||||||
@@ -112,19 +142,62 @@ def render_sidebar_content():
|
|||||||
else:
|
else:
|
||||||
st.info("Select a valid category to edit.")
|
st.info("Select a valid category to edit.")
|
||||||
|
|
||||||
|
@st.dialog("🔍 High-Res Inspection", width="large")
|
||||||
|
def view_high_res(img_path):
|
||||||
|
"""
|
||||||
|
Opens a modal and loads the ORIGINAL size image on demand.
|
||||||
|
We still compress to WebP (q=90) to ensure it sends fast,
|
||||||
|
but we do NOT resize the dimensions.
|
||||||
|
"""
|
||||||
|
# Load with target_size=None to keep original dimensions
|
||||||
|
# Quality=90 for high fidelity
|
||||||
|
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"Filename: {os.path.basename(img_path)}")
|
||||||
|
else:
|
||||||
|
st.error("Could not load full resolution image.")
|
||||||
|
|
||||||
# ... (Gallery Grid code remains exactly the same) ...
|
# ... (Gallery Grid code remains exactly the same) ...
|
||||||
|
# --- UPDATED CACHE FUNCTION ---
|
||||||
|
@st.cache_data(show_spinner=False, max_entries=2000)
|
||||||
|
def get_cached_thumbnail(path, quality, target_size, mtime):
|
||||||
|
# We pass the dynamic target_size here
|
||||||
|
return SorterEngine.compress_for_web(path, quality, target_size)
|
||||||
|
|
||||||
|
# --- UPDATED GALLERY FRAGMENT ---
|
||||||
@st.fragment
|
@st.fragment
|
||||||
def render_gallery_grid(current_batch, quality, grid_cols):
|
def render_gallery_grid(current_batch, quality, grid_cols, path_o): # <--- 1. Added path_o
|
||||||
staged = SorterEngine.get_staged_data()
|
staged = SorterEngine.get_staged_data()
|
||||||
history = SorterEngine.get_processed_log()
|
history = SorterEngine.get_processed_log()
|
||||||
selected_cat = st.session_state.get("t5_active_cat", "Default")
|
selected_cat = st.session_state.get("t5_active_cat", "Default")
|
||||||
tagging_disabled = selected_cat.startswith("---")
|
tagging_disabled = selected_cat.startswith("---")
|
||||||
|
|
||||||
|
# 2. Ensure global counter exists (default to 1)
|
||||||
|
if "t5_next_index" not in st.session_state: st.session_state.t5_next_index = 1
|
||||||
|
|
||||||
# --- NEW: LOAD ALL IMAGES IN PARALLEL ---
|
# 3. Smart Resolution (Wide screen assumption)
|
||||||
# This runs multithreaded and is much faster than the old loop
|
target_size = int(2400 / grid_cols)
|
||||||
batch_cache = SorterEngine.load_batch_parallel(current_batch, quality)
|
|
||||||
|
|
||||||
|
# 4. Parallel Load (16 threads for WebP)
|
||||||
|
import concurrent.futures
|
||||||
|
batch_cache = {}
|
||||||
|
|
||||||
|
def fetch_one(p):
|
||||||
|
try:
|
||||||
|
mtime = os.path.getmtime(p)
|
||||||
|
return p, get_cached_thumbnail(p, quality, target_size, mtime)
|
||||||
|
except:
|
||||||
|
return p, None
|
||||||
|
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=16) as executor:
|
||||||
|
future_to_path = {executor.submit(fetch_one, p): p for p in current_batch}
|
||||||
|
for future in concurrent.futures.as_completed(future_to_path):
|
||||||
|
p, data = future.result()
|
||||||
|
batch_cache[p] = data
|
||||||
|
|
||||||
|
# 5. 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)}"
|
unique_key = f"frag_{os.path.basename(img_path)}"
|
||||||
@@ -133,51 +206,84 @@ def render_gallery_grid(current_batch, quality, grid_cols):
|
|||||||
is_processed = img_path in history
|
is_processed = img_path in history
|
||||||
|
|
||||||
with st.container(border=True):
|
with st.container(border=True):
|
||||||
c_head1, c_head2 = st.columns([5, 1])
|
# Header: [Name] [Zoom] [Delete]
|
||||||
c_head1.caption(os.path.basename(img_path)[:15])
|
c_name, c_zoom, c_del = st.columns([4, 1, 1])
|
||||||
c_head2.button("❌", key=f"del_{unique_key}", on_click=cb_delete_image, args=(img_path,))
|
c_name.caption(os.path.basename(img_path)[:10])
|
||||||
|
|
||||||
|
if c_zoom.button("🔍", key=f"zoom_{unique_key}"):
|
||||||
|
view_high_res(img_path)
|
||||||
|
|
||||||
|
c_del.button("❌", key=f"del_{unique_key}", on_click=cb_delete_image, args=(img_path,))
|
||||||
|
|
||||||
|
# Status Banners
|
||||||
if is_staged:
|
if is_staged:
|
||||||
st.success(f"🏷️ {staged[img_path]['cat']}")
|
st.success(f"🏷️ {staged[img_path]['cat']}")
|
||||||
elif is_processed:
|
elif is_processed:
|
||||||
st.info(f"✅ {history[img_path]['action']} -> {history[img_path]['cat']}")
|
st.info(f"✅ {history[img_path]['action']}")
|
||||||
|
|
||||||
# --- CHANGED: USE PRE-LOADED DATA ---
|
# Image (Cached)
|
||||||
img_data = batch_cache.get(img_path)
|
img_data = batch_cache.get(img_path)
|
||||||
if img_data:
|
if img_data:
|
||||||
st.image(img_data, use_container_width=True)
|
st.image(img_data, use_container_width=True)
|
||||||
|
|
||||||
|
# Action Area
|
||||||
if not is_staged:
|
if not is_staged:
|
||||||
st.button("Tag", key=f"tag_{unique_key}", disabled=tagging_disabled, use_container_width=True,
|
# 6. Split Row: [Idx Input] [Tag Button]
|
||||||
on_click=cb_tag_image, args=(img_path, selected_cat))
|
c_idx, c_tag = st.columns([1, 2], vertical_alignment="bottom")
|
||||||
|
|
||||||
|
# Manual Override Box (Defaults to global session value)
|
||||||
|
card_index = c_idx.number_input(
|
||||||
|
"Idx",
|
||||||
|
min_value=1, step=1,
|
||||||
|
value=st.session_state.t5_next_index,
|
||||||
|
label_visibility="collapsed",
|
||||||
|
key=f"idx_{unique_key}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tag Button (Passes path_o for conflict check)
|
||||||
|
c_tag.button(
|
||||||
|
"Tag",
|
||||||
|
key=f"tag_{unique_key}",
|
||||||
|
disabled=tagging_disabled,
|
||||||
|
use_container_width=True,
|
||||||
|
on_click=cb_tag_image,
|
||||||
|
# Passing card_index + path_o is vital here
|
||||||
|
args=(img_path, selected_cat, card_index, path_o)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
st.button("Untag", key=f"untag_{unique_key}", use_container_width=True,
|
st.button("Untag", key=f"untag_{unique_key}", use_container_width=True,
|
||||||
on_click=cb_untag_image, args=(img_path,))
|
on_click=cb_untag_image, args=(img_path,))
|
||||||
|
|
||||||
|
|
||||||
# ... (Batch Actions code remains exactly the same) ...
|
# ... (Batch Actions code remains exactly the same) ...
|
||||||
@st.fragment
|
@st.fragment
|
||||||
def render_batch_actions(current_batch, path_o, page_num, path_s):
|
def render_batch_actions(current_batch, path_o, page_num, path_s):
|
||||||
st.write(f"### 🚀 Processing Actions")
|
st.write(f"### 🚀 Processing Actions")
|
||||||
st.caption("Settings apply to both Page and Global actions.")
|
st.caption("Settings apply to both Page and Global actions.")
|
||||||
|
|
||||||
c_set1, c_set2 = st.columns(2)
|
c_set1, c_set2 = st.columns(2)
|
||||||
op_mode = c_set1.radio("Tagged Files:", ["Move", "Copy"], horizontal=True, key="t5_op_mode")
|
|
||||||
|
# CHANGED: "Copy" is now first, making it the default
|
||||||
|
op_mode = c_set1.radio("Tagged Files:", ["Copy", "Move"], horizontal=True, key="t5_op_mode")
|
||||||
|
|
||||||
cleanup = c_set2.radio("Untagged Files:", ["Keep", "Move to Unused", "Delete"], horizontal=True, key="t5_cleanup_mode")
|
cleanup = c_set2.radio("Untagged Files:", ["Keep", "Move to Unused", "Delete"], horizontal=True, key="t5_cleanup_mode")
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
|
|
||||||
c_btn1, c_btn2 = st.columns(2)
|
c_btn1, c_btn2 = st.columns(2)
|
||||||
|
|
||||||
|
# BUTTON 1: APPLY PAGE
|
||||||
if c_btn1.button(f"APPLY PAGE {page_num}", type="secondary", use_container_width=True,
|
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)):
|
on_click=cb_apply_batch, args=(current_batch, path_o, cleanup, op_mode)):
|
||||||
st.toast(f"Page {page_num} Applied!")
|
st.toast(f"Page {page_num} Applied!")
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
|
# BUTTON 2: APPLY GLOBAL
|
||||||
if c_btn2.button("APPLY ALL (GLOBAL)", type="primary", use_container_width=True,
|
if c_btn2.button("APPLY ALL (GLOBAL)", type="primary", use_container_width=True,
|
||||||
help="Process ALL tagged files across all pages.",
|
help="Process ALL tagged files across all pages.",
|
||||||
on_click=cb_apply_global, args=(path_o, cleanup, op_mode, path_s)):
|
on_click=cb_apply_global, args=(path_o, cleanup, op_mode, path_s)):
|
||||||
st.toast("Global Apply Complete!")
|
st.toast("Global Apply Complete!")
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 4. MAIN RENDERER
|
# 4. MAIN RENDERER
|
||||||
# ==========================================
|
# ==========================================
|
||||||
@@ -237,7 +343,7 @@ def render(quality, profile_name):
|
|||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
nav_controls("top")
|
nav_controls("top")
|
||||||
render_gallery_grid(current_batch, quality, grid_cols)
|
render_gallery_grid(current_batch, quality, grid_cols, path_o)
|
||||||
st.divider()
|
st.divider()
|
||||||
nav_controls("bottom")
|
nav_controls("bottom")
|
||||||
st.divider()
|
st.divider()
|
||||||
|
|||||||
Reference in New Issue
Block a user