This commit enhances the IdentifyGUI class by updating the face identification process to handle similar faces more effectively. The logic for updating the current face index has been streamlined, allowing for better flow when identifying faces. Additionally, new methods have been introduced to manage selected similar faces, ensuring that all identified faces are properly marked and displayed. The form clearing functionality has also been updated to include similar face selections, improving user experience during the identification process.
1497 lines
83 KiB
Python
1497 lines
83 KiB
Python
"""
|
|
Legacy Tag Manager GUI ported intact into the refactored architecture.
|
|
|
|
This module preserves the original GUI and functionality exactly as in the old version.
|
|
"""
|
|
|
|
from typing import List, Dict
|
|
|
|
|
|
class TagManagerGUI:
|
|
"""GUI for managing tags, preserved from legacy implementation."""
|
|
|
|
def __init__(self, db_manager, gui_core, tag_manager, face_processor, verbose: int = 0):
|
|
self.db = db_manager
|
|
self.gui_core = gui_core
|
|
self.tag_manager = tag_manager
|
|
self.face_processor = face_processor
|
|
self.verbose = verbose
|
|
|
|
def tag_management(self) -> int:
|
|
"""Tag management GUI - file explorer-like interface for managing photo tags (legacy UI)."""
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox, simpledialog
|
|
from PIL import Image, ImageTk
|
|
import os
|
|
import sys
|
|
import subprocess
|
|
|
|
# Create the main window
|
|
root = tk.Tk()
|
|
root.title("Tag Management - Photo Explorer")
|
|
root.resizable(True, True)
|
|
|
|
# Track window state to prevent multiple destroy calls
|
|
window_destroyed = False
|
|
temp_crops: List[str] = []
|
|
photo_images: List[ImageTk.PhotoImage] = [] # Keep PhotoImage refs alive
|
|
|
|
# Track folder expand/collapse states
|
|
folder_states: Dict[str, bool] = {}
|
|
|
|
# Track pending tag changes/removals using tag IDs
|
|
pending_tag_changes: Dict[int, List[int]] = {}
|
|
pending_tag_removals: Dict[int, List[int]] = {}
|
|
# Track linkage type for pending additions: 0=single, 1=bulk
|
|
pending_tag_linkage_type: Dict[int, Dict[int, int]] = {}
|
|
|
|
# Helper: get saved linkage types for a photo {tag_id: type_int}
|
|
def get_saved_tag_types_for_photo(photo_id: int) -> Dict[int, int]:
|
|
types: Dict[int, int] = {}
|
|
try:
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT tag_id, linkage_type FROM phototaglinkage WHERE photo_id = ?', (photo_id,))
|
|
for row in cursor.fetchall():
|
|
try:
|
|
types[row[0]] = int(row[1]) if row[1] is not None else 0
|
|
except Exception:
|
|
types[row[0]] = 0
|
|
except Exception:
|
|
pass
|
|
return types
|
|
existing_tags: List[str] = []
|
|
tag_id_to_name: Dict[int, str] = {}
|
|
tag_name_to_id: Dict[str, int] = {}
|
|
|
|
# Hide window initially to prevent flash at corner
|
|
root.withdraw()
|
|
|
|
# Simple tooltip utility (local to this GUI)
|
|
class _ToolTip:
|
|
def __init__(self, widget, text: str):
|
|
self.widget = widget
|
|
self.text = text
|
|
self._tip = None
|
|
widget.bind("<Enter>", self._on)
|
|
widget.bind("<Leave>", self._off)
|
|
|
|
def _on(self, event=None):
|
|
if self._tip or not self.text:
|
|
return
|
|
try:
|
|
x = self.widget.winfo_rootx() + 20
|
|
y = self.widget.winfo_rooty() + 20
|
|
self._tip = tw = tk.Toplevel(self.widget)
|
|
tw.wm_overrideredirect(True)
|
|
tw.wm_geometry(f"+{x}+{y}")
|
|
lbl = tk.Label(tw, text=self.text, justify=tk.LEFT,
|
|
background="#ffffe0", relief=tk.SOLID, borderwidth=1,
|
|
font=("tahoma", "8", "normal"))
|
|
lbl.pack(ipadx=4, ipady=2)
|
|
except Exception:
|
|
self._tip = None
|
|
|
|
def _off(self, event=None):
|
|
if self._tip:
|
|
try:
|
|
self._tip.destroy()
|
|
except Exception:
|
|
pass
|
|
self._tip = None
|
|
|
|
# Close handler
|
|
def on_closing():
|
|
nonlocal window_destroyed
|
|
for crop in list(temp_crops):
|
|
try:
|
|
if os.path.exists(crop):
|
|
os.remove(crop)
|
|
except Exception:
|
|
pass
|
|
temp_crops.clear()
|
|
if not window_destroyed:
|
|
window_destroyed = True
|
|
try:
|
|
root.destroy()
|
|
except tk.TclError:
|
|
pass
|
|
|
|
root.protocol("WM_DELETE_WINDOW", on_closing)
|
|
|
|
# Window size saving (legacy behavior)
|
|
try:
|
|
self.gui_core.setup_window_size_saving(root, "gui_config.json")
|
|
except Exception:
|
|
pass
|
|
|
|
# Main containers
|
|
main_frame = ttk.Frame(root, padding="10")
|
|
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
|
|
root.columnconfigure(0, weight=1)
|
|
root.rowconfigure(0, weight=1)
|
|
main_frame.columnconfigure(0, weight=1)
|
|
main_frame.rowconfigure(1, weight=1)
|
|
main_frame.rowconfigure(2, weight=0)
|
|
|
|
header_frame = ttk.Frame(main_frame)
|
|
header_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10))
|
|
header_frame.columnconfigure(1, weight=1)
|
|
|
|
title_label = ttk.Label(header_frame, text="Photo Explorer - Tag Management", font=("Arial", 16, "bold"))
|
|
title_label.grid(row=0, column=0, sticky=tk.W)
|
|
|
|
# View controls
|
|
view_frame = ttk.Frame(header_frame)
|
|
view_frame.grid(row=0, column=1, sticky=tk.E)
|
|
view_mode_var = tk.StringVar(value="list")
|
|
ttk.Label(view_frame, text="View:").pack(side=tk.LEFT, padx=(0, 5))
|
|
ttk.Radiobutton(view_frame, text="List", variable=view_mode_var, value="list", command=lambda: switch_view_mode("list")).pack(side=tk.LEFT, padx=(0, 5))
|
|
ttk.Radiobutton(view_frame, text="Icons", variable=view_mode_var, value="icons", command=lambda: switch_view_mode("icons")).pack(side=tk.LEFT, padx=(0, 5))
|
|
ttk.Radiobutton(view_frame, text="Compact", variable=view_mode_var, value="compact", command=lambda: switch_view_mode("compact")).pack(side=tk.LEFT)
|
|
|
|
# Manage Tags button (legacy dialog)
|
|
def open_manage_tags_dialog():
|
|
dialog = tk.Toplevel(root)
|
|
dialog.title("Manage Tags")
|
|
dialog.transient(root)
|
|
dialog.grab_set()
|
|
dialog.geometry("500x500")
|
|
|
|
top_frame = ttk.Frame(dialog, padding="8")
|
|
top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E))
|
|
list_frame = ttk.Frame(dialog, padding="8")
|
|
list_frame.grid(row=1, column=0, sticky=(tk.N, tk.S, tk.W, tk.E))
|
|
bottom_frame = ttk.Frame(dialog, padding="8")
|
|
bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E))
|
|
|
|
dialog.columnconfigure(0, weight=1)
|
|
dialog.rowconfigure(1, weight=1)
|
|
|
|
new_tag_var = tk.StringVar()
|
|
new_tag_entry = ttk.Entry(top_frame, textvariable=new_tag_var, width=30)
|
|
new_tag_entry.grid(row=0, column=0, padx=(0, 8), sticky=(tk.W, tk.E))
|
|
|
|
def add_new_tag():
|
|
tag_name = new_tag_var.get().strip()
|
|
if not tag_name:
|
|
return
|
|
try:
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,))
|
|
conn.commit()
|
|
new_tag_var.set("")
|
|
refresh_tag_list()
|
|
load_existing_tags()
|
|
switch_view_mode(view_mode_var.get())
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"Failed to add tag: {e}")
|
|
|
|
add_btn = ttk.Button(top_frame, text="Add tag", command=add_new_tag)
|
|
add_btn.grid(row=0, column=1, sticky=tk.W)
|
|
top_frame.columnconfigure(0, weight=1)
|
|
|
|
canvas = tk.Canvas(list_frame, highlightthickness=0)
|
|
scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview)
|
|
rows_container = ttk.Frame(canvas)
|
|
canvas.create_window((0, 0), window=rows_container, anchor="nw")
|
|
canvas.configure(yscrollcommand=scrollbar.set)
|
|
canvas.grid(row=0, column=0, sticky=(tk.N, tk.S, tk.W, tk.E))
|
|
scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S))
|
|
list_frame.columnconfigure(0, weight=1)
|
|
list_frame.rowconfigure(0, weight=1)
|
|
rows_container.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
|
|
|
|
selected_tag_vars: Dict[int, tk.BooleanVar] = {}
|
|
current_tags: List[Dict] = []
|
|
|
|
def refresh_tag_list():
|
|
for child in list(rows_container.winfo_children()):
|
|
child.destroy()
|
|
selected_tag_vars.clear()
|
|
current_tags.clear()
|
|
try:
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id, tag_name FROM tags ORDER BY tag_name COLLATE NOCASE')
|
|
for row in cursor.fetchall():
|
|
current_tags.append({'id': row[0], 'tag_name': row[1]})
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"Failed to load tags: {e}")
|
|
return
|
|
head = ttk.Frame(rows_container)
|
|
head.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 6))
|
|
ttk.Label(head, text="Delete").pack(side=tk.LEFT, padx=(0, 10))
|
|
ttk.Label(head, text="Tag name", width=30).pack(side=tk.LEFT)
|
|
ttk.Label(head, text="Edit", width=6).pack(side=tk.LEFT, padx=(10, 0))
|
|
|
|
for idx, tag in enumerate(current_tags, start=1):
|
|
row = ttk.Frame(rows_container)
|
|
row.grid(row=idx, column=0, sticky=(tk.W, tk.E), pady=2)
|
|
var = tk.BooleanVar(value=False)
|
|
selected_tag_vars[tag['id']] = var
|
|
ttk.Checkbutton(row, variable=var).pack(side=tk.LEFT, padx=(0, 10))
|
|
name_label = ttk.Label(row, text=tag['tag_name'], width=30)
|
|
name_label.pack(side=tk.LEFT)
|
|
|
|
def make_edit_handler(tag_id, name_widget):
|
|
def handler():
|
|
new_name = simpledialog.askstring("Edit Tag", "Enter new tag name:", initialvalue=name_widget.cget('text'), parent=dialog)
|
|
if new_name is None:
|
|
return
|
|
new_name = new_name.strip()
|
|
if not new_name:
|
|
return
|
|
try:
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('UPDATE tags SET tag_name = ? WHERE id = ?', (new_name, tag_id))
|
|
conn.commit()
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"Failed to rename tag: {e}")
|
|
return
|
|
refresh_tag_list()
|
|
load_existing_tags()
|
|
switch_view_mode(view_mode_var.get())
|
|
return handler
|
|
|
|
ttk.Button(row, text="Edit", width=6, command=make_edit_handler(tag['id'], name_label)).pack(side=tk.LEFT, padx=(10, 0))
|
|
|
|
refresh_tag_list()
|
|
|
|
def delete_selected():
|
|
ids_to_delete = [tid for tid, v in selected_tag_vars.items() if v.get()]
|
|
if not ids_to_delete:
|
|
return
|
|
if not messagebox.askyesno("Confirm Delete", f"Delete {len(ids_to_delete)} selected tag(s)? This will unlink them from photos."):
|
|
return
|
|
try:
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(f"DELETE FROM phototaglinkage WHERE tag_id IN ({','.join('?' for _ in ids_to_delete)})", ids_to_delete)
|
|
cursor.execute(f"DELETE FROM tags WHERE id IN ({','.join('?' for _ in ids_to_delete)})", ids_to_delete)
|
|
conn.commit()
|
|
|
|
for photo_id in list(pending_tag_changes.keys()):
|
|
pending_tag_changes[photo_id] = [tid for tid in pending_tag_changes[photo_id] if tid not in ids_to_delete]
|
|
if not pending_tag_changes[photo_id]:
|
|
del pending_tag_changes[photo_id]
|
|
for photo_id in list(pending_tag_removals.keys()):
|
|
pending_tag_removals[photo_id] = [tid for tid in pending_tag_removals[photo_id] if tid not in ids_to_delete]
|
|
if not pending_tag_removals[photo_id]:
|
|
del pending_tag_removals[photo_id]
|
|
|
|
refresh_tag_list()
|
|
load_existing_tags()
|
|
load_photos()
|
|
switch_view_mode(view_mode_var.get())
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"Failed to delete tags: {e}")
|
|
|
|
ttk.Button(bottom_frame, text="Delete selected tags", command=delete_selected).pack(side=tk.LEFT)
|
|
ttk.Button(bottom_frame, text="Quit", command=dialog.destroy).pack(side=tk.RIGHT)
|
|
new_tag_entry.focus_set()
|
|
|
|
ttk.Button(header_frame, text="Manage Tags", command=open_manage_tags_dialog).grid(row=0, column=2, sticky=tk.E, padx=(10, 0))
|
|
|
|
# Content area
|
|
content_frame = ttk.Frame(main_frame)
|
|
content_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
content_frame.columnconfigure(0, weight=1)
|
|
content_frame.rowconfigure(0, weight=1)
|
|
|
|
style = ttk.Style()
|
|
canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9'
|
|
|
|
content_canvas = tk.Canvas(content_frame, bg=canvas_bg_color, highlightthickness=0)
|
|
content_scrollbar = ttk.Scrollbar(content_frame, orient="vertical", command=content_canvas.yview)
|
|
content_inner = ttk.Frame(content_canvas)
|
|
content_canvas.create_window((0, 0), window=content_inner, anchor="nw")
|
|
content_canvas.configure(yscrollcommand=content_scrollbar.set)
|
|
content_inner.bind("<Configure>", lambda e: content_canvas.configure(scrollregion=content_canvas.bbox("all")))
|
|
content_canvas.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
content_scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S))
|
|
|
|
bottom_frame = ttk.Frame(main_frame)
|
|
bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E), pady=(10, 0))
|
|
save_button = ttk.Button(bottom_frame, text="Save Tagging")
|
|
save_button.pack(side=tk.RIGHT, padx=10, pady=5)
|
|
|
|
def open_photo(photo_path: str):
|
|
if not os.path.exists(photo_path):
|
|
try:
|
|
messagebox.showerror("File not found", f"Photo does not exist:\n{photo_path}")
|
|
except Exception:
|
|
pass
|
|
return
|
|
try:
|
|
# Open in an in-app preview window sized reasonably compared to the main GUI
|
|
img = Image.open(photo_path)
|
|
screen_w = root.winfo_screenwidth()
|
|
screen_h = root.winfo_screenheight()
|
|
max_w = int(min(1000, screen_w * 0.6))
|
|
max_h = int(min(800, screen_h * 0.6))
|
|
preview = tk.Toplevel(root)
|
|
preview.title(os.path.basename(photo_path))
|
|
preview.transient(root)
|
|
# Resize image to fit nicely while keeping aspect ratio
|
|
img_copy = img.copy()
|
|
img_copy.thumbnail((max_w, max_h), Image.Resampling.LANCZOS)
|
|
photo_img = ImageTk.PhotoImage(img_copy)
|
|
photo_images.append(photo_img)
|
|
pad = 12
|
|
w, h = photo_img.width(), photo_img.height()
|
|
# Center the window roughly relative to screen
|
|
x = int((screen_w - (w + pad)) / 2)
|
|
y = int((screen_h - (h + pad)) / 2)
|
|
preview.geometry(f"{w + pad}x{h + pad}+{max(x,0)}+{max(y,0)}")
|
|
canvas = tk.Canvas(preview, width=w, height=h, highlightthickness=0)
|
|
canvas.pack(padx=pad//2, pady=pad//2)
|
|
canvas.create_image(w // 2, h // 2, image=photo_img)
|
|
try:
|
|
_ToolTip(canvas, os.path.basename(photo_path))
|
|
except Exception:
|
|
pass
|
|
preview.focus_set()
|
|
except Exception:
|
|
# Fallback to system default opener if preview fails for any reason
|
|
try:
|
|
if sys.platform.startswith('linux'):
|
|
subprocess.Popen(['xdg-open', photo_path])
|
|
elif sys.platform == 'darwin':
|
|
subprocess.Popen(['open', photo_path])
|
|
elif os.name == 'nt':
|
|
os.startfile(photo_path) # type: ignore[attr-defined]
|
|
else:
|
|
Image.open(photo_path).show()
|
|
except Exception as e:
|
|
try:
|
|
messagebox.showerror("Error", f"Failed to open photo:\n{e}")
|
|
except Exception:
|
|
pass
|
|
|
|
def quit_with_warning():
|
|
has_pending_changes = bool(pending_tag_changes or pending_tag_removals)
|
|
if has_pending_changes:
|
|
total_additions = sum(len(tags) for tags in pending_tag_changes.values())
|
|
total_removals = sum(len(tags) for tags in pending_tag_removals.values())
|
|
changes_text = []
|
|
if total_additions > 0:
|
|
changes_text.append(f"{total_additions} tag addition(s)")
|
|
if total_removals > 0:
|
|
changes_text.append(f"{total_removals} tag removal(s)")
|
|
changes_summary = " and ".join(changes_text)
|
|
result = messagebox.askyesnocancel(
|
|
"Unsaved Changes",
|
|
f"You have unsaved changes: {changes_summary}.\n\nDo you want to save your changes before quitting?\n\nYes = Save and quit\nNo = Quit without saving\nCancel = Stay in dialog"
|
|
)
|
|
if result is True:
|
|
save_tagging_changes()
|
|
root.destroy()
|
|
elif result is False:
|
|
root.destroy()
|
|
else:
|
|
root.destroy()
|
|
|
|
ttk.Button(bottom_frame, text="Quit", command=quit_with_warning).pack(side=tk.RIGHT, padx=(0, 10), pady=5)
|
|
|
|
def on_mousewheel(event):
|
|
content_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
|
|
|
|
resize_start_x = 0
|
|
resize_start_widths: List[int] = []
|
|
current_visible_cols: List[Dict] = []
|
|
is_resizing = False
|
|
|
|
def start_resize(event, col_idx):
|
|
nonlocal resize_start_x, resize_start_widths, is_resizing
|
|
is_resizing = True
|
|
resize_start_x = event.x_root
|
|
resize_start_widths = [col['width'] for col in current_visible_cols]
|
|
root.configure(cursor="sb_h_double_arrow")
|
|
|
|
def do_resize(event, col_idx):
|
|
nonlocal resize_start_x, resize_start_widths, is_resizing
|
|
if not is_resizing or not resize_start_widths or not current_visible_cols:
|
|
return
|
|
delta_x = event.x_root - resize_start_x
|
|
if col_idx < len(current_visible_cols) and col_idx + 1 < len(current_visible_cols):
|
|
new_width_left = max(50, resize_start_widths[col_idx] + delta_x)
|
|
new_width_right = max(50, resize_start_widths[col_idx + 1] - delta_x)
|
|
current_visible_cols[col_idx]['width'] = new_width_left
|
|
current_visible_cols[col_idx + 1]['width'] = new_width_right
|
|
for i, col in enumerate(column_config['list']):
|
|
if col['key'] == current_visible_cols[col_idx]['key']:
|
|
column_config['list'][i]['width'] = new_width_left
|
|
elif col['key'] == current_visible_cols[col_idx + 1]['key']:
|
|
column_config['list'][i]['width'] = new_width_right
|
|
try:
|
|
header_frame_ref = None
|
|
row_frames = []
|
|
for widget in content_inner.winfo_children():
|
|
if isinstance(widget, ttk.Frame):
|
|
if header_frame_ref is None:
|
|
header_frame_ref = widget
|
|
else:
|
|
row_frames.append(widget)
|
|
if header_frame_ref is not None:
|
|
header_frame_ref.columnconfigure(col_idx * 2, weight=current_visible_cols[col_idx]['weight'], minsize=new_width_left)
|
|
header_frame_ref.columnconfigure((col_idx + 1) * 2, weight=current_visible_cols[col_idx + 1]['weight'], minsize=new_width_right)
|
|
for rf in row_frames:
|
|
rf.columnconfigure(col_idx, weight=current_visible_cols[col_idx]['weight'], minsize=new_width_left)
|
|
rf.columnconfigure(col_idx + 1, weight=current_visible_cols[col_idx + 1]['weight'], minsize=new_width_right)
|
|
root.update_idletasks()
|
|
except Exception:
|
|
pass
|
|
|
|
def stop_resize(event):
|
|
nonlocal is_resizing
|
|
is_resizing = False
|
|
root.configure(cursor="")
|
|
|
|
root.bind_all("<MouseWheel>", on_mousewheel)
|
|
|
|
def global_mouse_release(event):
|
|
if is_resizing:
|
|
stop_resize(event)
|
|
root.bind_all("<ButtonRelease-1>", global_mouse_release)
|
|
|
|
def cleanup_mousewheel():
|
|
try:
|
|
root.unbind_all("<MouseWheel>")
|
|
root.unbind_all("<ButtonRelease-1>")
|
|
except Exception:
|
|
pass
|
|
|
|
root.bind("<Destroy>", lambda e: cleanup_mousewheel())
|
|
|
|
photos_data: List[Dict] = []
|
|
people_names_cache: Dict[int, List[str]] = {} # {photo_id: [list of people names]}
|
|
|
|
column_visibility = {
|
|
'list': {'id': True, 'filename': True, 'path': True, 'processed': True, 'date_taken': True, 'faces': True, 'tags': True},
|
|
'icons': {'thumbnail': True, 'id': True, 'filename': True, 'processed': True, 'date_taken': True, 'faces': True, 'tags': True},
|
|
'compact': {'filename': True, 'faces': True, 'tags': True}
|
|
}
|
|
|
|
column_config = {
|
|
'list': [
|
|
{'key': 'id', 'label': 'ID', 'width': 50, 'weight': 0},
|
|
{'key': 'filename', 'label': 'Filename', 'width': 150, 'weight': 1},
|
|
{'key': 'path', 'label': 'Path', 'width': 200, 'weight': 2},
|
|
{'key': 'processed', 'label': 'Processed', 'width': 80, 'weight': 0},
|
|
{'key': 'date_taken', 'label': 'Date Taken', 'width': 120, 'weight': 0},
|
|
{'key': 'faces', 'label': 'Faces', 'width': 60, 'weight': 0},
|
|
{'key': 'tags', 'label': 'Tags', 'width': 180, 'weight': 1}
|
|
],
|
|
'icons': [
|
|
{'key': 'thumbnail', 'label': 'Thumbnail', 'width': 160, 'weight': 0},
|
|
{'key': 'id', 'label': 'ID', 'width': 80, 'weight': 0},
|
|
{'key': 'filename', 'label': 'Filename', 'width': 200, 'weight': 1},
|
|
{'key': 'processed', 'label': 'Processed', 'width': 80, 'weight': 0},
|
|
{'key': 'date_taken', 'label': 'Date Taken', 'width': 120, 'weight': 0},
|
|
{'key': 'faces', 'label': 'Faces', 'width': 60, 'weight': 0},
|
|
{'key': 'tags', 'label': 'Tags', 'width': 180, 'weight': 1}
|
|
],
|
|
'compact': [
|
|
{'key': 'filename', 'label': 'Filename', 'width': 200, 'weight': 1},
|
|
{'key': 'faces', 'label': 'Faces', 'width': 60, 'weight': 0},
|
|
{'key': 'tags', 'label': 'Tags', 'width': 180, 'weight': 1}
|
|
]
|
|
}
|
|
|
|
def show_people_names_popup(photo_id: int, photo_filename: str):
|
|
"""Show a popup window with the names of people identified in this photo"""
|
|
nonlocal people_names_cache
|
|
people_names = people_names_cache.get(photo_id, [])
|
|
if not people_names:
|
|
try:
|
|
messagebox.showinfo("No People Identified", f"No people have been identified in {photo_filename}")
|
|
except Exception:
|
|
pass
|
|
return
|
|
|
|
popup = tk.Toplevel(root)
|
|
popup.title(f"People in {photo_filename}")
|
|
popup.transient(root)
|
|
popup.geometry("400x300")
|
|
popup.resizable(True, True)
|
|
# Don't use grab_set() as it can cause issues
|
|
# popup.grab_set()
|
|
|
|
# Header
|
|
header_frame = ttk.Frame(popup, padding="10")
|
|
header_frame.pack(fill=tk.X)
|
|
ttk.Label(header_frame, text=f"People identified in:", font=("Arial", 12, "bold")).pack(anchor=tk.W)
|
|
ttk.Label(header_frame, text=photo_filename, font=("Arial", 10)).pack(anchor=tk.W, pady=(2, 0))
|
|
|
|
# List of people
|
|
list_frame = ttk.Frame(popup, padding="10")
|
|
list_frame.pack(fill=tk.BOTH, expand=True)
|
|
|
|
canvas = tk.Canvas(list_frame, highlightthickness=0)
|
|
scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview)
|
|
scrollable_frame = ttk.Frame(canvas)
|
|
scrollable_frame.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
|
|
canvas.create_window((0, 0), window=scrollable_frame, anchor="nw")
|
|
canvas.configure(yscrollcommand=scrollbar.set)
|
|
canvas.pack(side="left", fill="both", expand=True)
|
|
scrollbar.pack(side="right", fill="y")
|
|
|
|
for i, name in enumerate(people_names):
|
|
ttk.Label(scrollable_frame, text=f"• {name}", font=("Arial", 11)).pack(anchor=tk.W, pady=2)
|
|
|
|
# Close button
|
|
button_frame = ttk.Frame(popup, padding="10")
|
|
button_frame.pack(fill=tk.X)
|
|
ttk.Button(button_frame, text="Close", command=popup.destroy).pack(side=tk.RIGHT)
|
|
|
|
popup.focus_set()
|
|
|
|
def load_photos():
|
|
nonlocal photos_data, people_names_cache
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT p.id, p.filename, p.path, p.processed, p.date_taken, p.date_added,
|
|
(SELECT COUNT(*) FROM faces f WHERE f.photo_id = p.id) as face_count,
|
|
(SELECT GROUP_CONCAT(DISTINCT t.tag_name)
|
|
FROM phototaglinkage ptl
|
|
JOIN tags t ON t.id = ptl.tag_id
|
|
WHERE ptl.photo_id = p.id) as tags
|
|
FROM photos p
|
|
ORDER BY p.date_taken DESC, p.filename
|
|
''')
|
|
photos_data = []
|
|
for row in cursor.fetchall():
|
|
photos_data.append({
|
|
'id': row[0],
|
|
'filename': row[1],
|
|
'path': row[2],
|
|
'processed': row[3],
|
|
'date_taken': row[4],
|
|
'date_added': row[5],
|
|
'face_count': row[6] or 0,
|
|
'tags': row[7] or ""
|
|
})
|
|
|
|
# Cache people names for each photo
|
|
people_names_cache.clear()
|
|
cursor.execute('''
|
|
SELECT f.photo_id, pe.first_name, pe.last_name
|
|
FROM faces f
|
|
JOIN people pe ON pe.id = f.person_id
|
|
WHERE f.person_id IS NOT NULL
|
|
ORDER BY pe.last_name, pe.first_name
|
|
''')
|
|
cache_rows = cursor.fetchall()
|
|
for row in cache_rows:
|
|
photo_id, first_name, last_name = row
|
|
if photo_id not in people_names_cache:
|
|
people_names_cache[photo_id] = []
|
|
# Format as "Last, First" or just "First" if no last name
|
|
if last_name and first_name:
|
|
name = f"{last_name}, {first_name}"
|
|
else:
|
|
name = first_name or last_name or "Unknown"
|
|
if name not in people_names_cache[photo_id]:
|
|
people_names_cache[photo_id].append(name)
|
|
|
|
def prepare_folder_grouped_data():
|
|
from collections import defaultdict
|
|
folder_groups = defaultdict(list)
|
|
for photo in photos_data:
|
|
folder_path = os.path.dirname(photo['path'])
|
|
folder_groups[folder_path].append(photo)
|
|
sorted_folders = []
|
|
for folder_path in sorted(folder_groups.keys()):
|
|
folder_name = os.path.basename(folder_path) if folder_path else "Root"
|
|
photos_in_folder = sorted(folder_groups[folder_path], key=lambda x: x['date_taken'] or '', reverse=True)
|
|
if folder_path not in folder_states:
|
|
# Collapse folders by default on first load
|
|
folder_states[folder_path] = False
|
|
sorted_folders.append({
|
|
'folder_path': folder_path,
|
|
'folder_name': folder_name,
|
|
'photos': photos_in_folder,
|
|
'photo_count': len(photos_in_folder)
|
|
})
|
|
return sorted_folders
|
|
|
|
def create_folder_header(parent, folder_info, current_row, col_count, view_mode):
|
|
folder_header_frame = ttk.Frame(parent)
|
|
folder_header_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(10, 5))
|
|
folder_header_frame.configure(relief='raised', borderwidth=1)
|
|
|
|
def open_bulk_link_dialog():
|
|
# Bulk tagging dialog: add selected tag to all photos in this folder (pending changes only)
|
|
popup = tk.Toplevel(root)
|
|
popup.title("Bulk Link Tags to Folder")
|
|
popup.transient(root)
|
|
popup.grab_set()
|
|
popup.geometry("520x420")
|
|
|
|
top_frame = ttk.Frame(popup, padding="8")
|
|
top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E))
|
|
status_frame = ttk.Frame(popup, padding="8")
|
|
status_frame.grid(row=1, column=0, sticky=(tk.W, tk.E))
|
|
bottom_frame = ttk.Frame(popup, padding="8")
|
|
# Place bottom frame below the list to keep actions at the bottom
|
|
bottom_frame.grid(row=4, column=0, sticky=(tk.W, tk.E))
|
|
popup.columnconfigure(0, weight=1)
|
|
|
|
ttk.Label(top_frame, text=f"Folder: {folder_info['folder_name']} ({folder_info['photo_count']} photos)").grid(row=0, column=0, columnspan=3, sticky=tk.W, pady=(0,6))
|
|
ttk.Label(top_frame, text="Add tag to all photos:").grid(row=1, column=0, padx=(0, 8), sticky=tk.W)
|
|
|
|
bulk_tag_var = tk.StringVar()
|
|
combo = ttk.Combobox(top_frame, textvariable=bulk_tag_var, values=existing_tags, width=30, state='readonly')
|
|
combo.grid(row=1, column=1, padx=(0, 8), sticky=(tk.W, tk.E))
|
|
combo.focus_set()
|
|
|
|
result_var = tk.StringVar(value="")
|
|
ttk.Label(status_frame, textvariable=result_var, foreground="gray").grid(row=0, column=0, sticky=tk.W)
|
|
|
|
def add_bulk_tag():
|
|
tag_name = bulk_tag_var.get().strip()
|
|
if not tag_name:
|
|
return
|
|
# Resolve or create tag id
|
|
if tag_name in tag_name_to_id:
|
|
tag_id = tag_name_to_id[tag_name]
|
|
else:
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,))
|
|
cursor.execute('SELECT id FROM tags WHERE tag_name = ?', (tag_name,))
|
|
tag_id = cursor.fetchone()[0]
|
|
tag_name_to_id[tag_name] = tag_id
|
|
tag_id_to_name[tag_id] = tag_name
|
|
if tag_name not in existing_tags:
|
|
existing_tags.append(tag_name)
|
|
existing_tags.sort()
|
|
|
|
affected = 0
|
|
for photo in folder_info.get('photos', []):
|
|
photo_id = photo['id']
|
|
saved_types = get_saved_tag_types_for_photo(photo_id)
|
|
existing_tag_ids = list(saved_types.keys())
|
|
pending_tag_ids = pending_tag_changes.get(photo_id, [])
|
|
# Case 1: not present anywhere → add as pending bulk
|
|
if tag_id not in existing_tag_ids and tag_id not in pending_tag_ids:
|
|
if photo_id not in pending_tag_changes:
|
|
pending_tag_changes[photo_id] = []
|
|
pending_tag_changes[photo_id].append(tag_id)
|
|
if photo_id not in pending_tag_linkage_type:
|
|
pending_tag_linkage_type[photo_id] = {}
|
|
pending_tag_linkage_type[photo_id][tag_id] = 1
|
|
affected += 1
|
|
# Case 2: already pending as single → upgrade pending type to bulk
|
|
elif tag_id in pending_tag_ids:
|
|
if photo_id not in pending_tag_linkage_type:
|
|
pending_tag_linkage_type[photo_id] = {}
|
|
prev_type = pending_tag_linkage_type[photo_id].get(tag_id)
|
|
if prev_type != 1:
|
|
pending_tag_linkage_type[photo_id][tag_id] = 1
|
|
affected += 1
|
|
# Case 3: saved as single → schedule an upgrade by adding to pending and setting type to bulk
|
|
elif saved_types.get(tag_id) == 0:
|
|
if photo_id not in pending_tag_changes:
|
|
pending_tag_changes[photo_id] = []
|
|
if tag_id not in pending_tag_changes[photo_id]:
|
|
pending_tag_changes[photo_id].append(tag_id)
|
|
if photo_id not in pending_tag_linkage_type:
|
|
pending_tag_linkage_type[photo_id] = {}
|
|
pending_tag_linkage_type[photo_id][tag_id] = 1
|
|
affected += 1
|
|
# Case 4: saved as bulk → nothing to do
|
|
|
|
update_save_button_text()
|
|
# Refresh main view to reflect updated pending tags in each row
|
|
switch_view_mode(view_mode)
|
|
result_var.set(f"Added pending tag to {affected} photo(s)")
|
|
bulk_tag_var.set("")
|
|
# Refresh the bulk list to immediately reflect pending adds
|
|
try:
|
|
refresh_bulk_tag_list()
|
|
except Exception:
|
|
pass
|
|
|
|
ttk.Button(top_frame, text="Add", command=add_bulk_tag).grid(row=1, column=2)
|
|
|
|
# Section: Remove bulk-linked tags across this folder
|
|
ttk.Separator(status_frame, orient='horizontal').grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(8, 6))
|
|
# removed helper label per request
|
|
|
|
list_frame = ttk.Frame(popup, padding="8")
|
|
list_frame.grid(row=3, column=0, sticky=(tk.N, tk.S, tk.W, tk.E))
|
|
popup.rowconfigure(3, weight=1)
|
|
list_canvas = tk.Canvas(list_frame, highlightthickness=0)
|
|
list_scroll = ttk.Scrollbar(list_frame, orient="vertical", command=list_canvas.yview)
|
|
list_inner = ttk.Frame(list_canvas)
|
|
list_canvas.create_window((0, 0), window=list_inner, anchor="nw")
|
|
list_canvas.configure(yscrollcommand=list_scroll.set)
|
|
list_canvas.grid(row=0, column=0, sticky=(tk.N, tk.S, tk.W, tk.E))
|
|
list_scroll.grid(row=0, column=1, sticky=(tk.N, tk.S))
|
|
list_frame.columnconfigure(0, weight=1)
|
|
list_frame.rowconfigure(0, weight=1)
|
|
list_inner.bind("<Configure>", lambda e: list_canvas.configure(scrollregion=list_canvas.bbox("all")))
|
|
|
|
bulk_tag_vars: Dict[int, tk.BooleanVar] = {}
|
|
|
|
def refresh_bulk_tag_list():
|
|
for child in list(list_inner.winfo_children()):
|
|
child.destroy()
|
|
bulk_tag_vars.clear()
|
|
# Aggregate bulk-linked tags across all photos in this folder
|
|
tag_id_counts: Dict[int, int] = {}
|
|
pending_add_counts: Dict[int, int] = {}
|
|
for photo in folder_info.get('photos', []):
|
|
saved_types = get_saved_tag_types_for_photo(photo['id'])
|
|
for tid, ltype in saved_types.items():
|
|
if ltype == 1:
|
|
tag_id_counts[tid] = tag_id_counts.get(tid, 0) + 1
|
|
# Count pending additions (bulk type) for this photo
|
|
for tid in pending_tag_changes.get(photo['id'], []):
|
|
if pending_tag_linkage_type.get(photo['id'], {}).get(tid) == 1:
|
|
pending_add_counts[tid] = pending_add_counts.get(tid, 0) + 1
|
|
# Include tags that exist only in pending adds
|
|
all_tag_ids = set(tag_id_counts.keys()) | set(pending_add_counts.keys())
|
|
if not all_tag_ids:
|
|
ttk.Label(list_inner, text="No bulk-linked tags in this folder", foreground="gray").pack(anchor=tk.W, pady=5)
|
|
return
|
|
for tid in sorted(all_tag_ids, key=lambda x: tag_id_to_name.get(x, "")):
|
|
row = ttk.Frame(list_inner)
|
|
row.pack(fill=tk.X, pady=1)
|
|
var = tk.BooleanVar(value=False)
|
|
bulk_tag_vars[tid] = var
|
|
ttk.Checkbutton(row, variable=var).pack(side=tk.LEFT, padx=(0, 6))
|
|
pend_suffix = " (pending)" if pending_add_counts.get(tid, 0) > 0 else ""
|
|
ttk.Label(row, text=f"{tag_id_to_name.get(tid, 'Unknown')}{pend_suffix}").pack(side=tk.LEFT)
|
|
|
|
def remove_selected_bulk_tags():
|
|
selected_tids = [tid for tid, v in bulk_tag_vars.items() if v.get()]
|
|
if not selected_tids:
|
|
return
|
|
affected = 0
|
|
for photo in folder_info.get('photos', []):
|
|
photo_id = photo['id']
|
|
saved_types = get_saved_tag_types_for_photo(photo_id)
|
|
for tid in selected_tids:
|
|
# If it's pending add (bulk), cancel the pending change
|
|
if tid in pending_tag_changes.get(photo_id, []) and pending_tag_linkage_type.get(photo_id, {}).get(tid) == 1:
|
|
pending_tag_changes[photo_id] = [x for x in pending_tag_changes[photo_id] if x != tid]
|
|
if not pending_tag_changes[photo_id]:
|
|
del pending_tag_changes[photo_id]
|
|
if photo_id in pending_tag_linkage_type and tid in pending_tag_linkage_type[photo_id]:
|
|
del pending_tag_linkage_type[photo_id][tid]
|
|
if not pending_tag_linkage_type[photo_id]:
|
|
del pending_tag_linkage_type[photo_id]
|
|
affected += 1
|
|
# Else if it's a saved bulk linkage, mark for removal
|
|
elif saved_types.get(tid) == 1:
|
|
if photo_id not in pending_tag_removals:
|
|
pending_tag_removals[photo_id] = []
|
|
if tid not in pending_tag_removals[photo_id]:
|
|
pending_tag_removals[photo_id].append(tid)
|
|
affected += 1
|
|
update_save_button_text()
|
|
switch_view_mode(view_mode)
|
|
result_var.set(f"Marked bulk tag removals affecting {affected} linkage(s)")
|
|
refresh_bulk_tag_list()
|
|
|
|
ttk.Button(bottom_frame, text="Remove selected bulk tags", command=remove_selected_bulk_tags).pack(side=tk.LEFT)
|
|
ttk.Button(bottom_frame, text="Close", command=popup.destroy).pack(side=tk.RIGHT)
|
|
refresh_bulk_tag_list()
|
|
|
|
is_expanded = folder_states.get(folder_info['folder_path'], True)
|
|
toggle_text = "▼" if is_expanded else "▶"
|
|
toggle_button = tk.Button(folder_header_frame, text=toggle_text, width=2, height=1,
|
|
command=lambda: toggle_folder(folder_info['folder_path'], view_mode),
|
|
font=("Arial", 8), relief='flat', bd=1)
|
|
toggle_button.pack(side=tk.LEFT, padx=(5, 2), pady=5)
|
|
|
|
folder_label = ttk.Label(folder_header_frame, text=f"📁 {folder_info['folder_name']} ({folder_info['photo_count']} photos)", font=("Arial", 11, "bold"))
|
|
folder_label.pack(side=tk.LEFT, padx=(0, 10), pady=5)
|
|
|
|
# Bulk linkage icon (applies selected tag to all photos in this folder)
|
|
bulk_link_btn = tk.Button(folder_header_frame, text="🔗", width=2, command=open_bulk_link_dialog)
|
|
bulk_link_btn.pack(side=tk.LEFT, padx=(6, 6))
|
|
try:
|
|
_ToolTip(bulk_link_btn, "Bulk link tags to all photos in this folder")
|
|
except Exception:
|
|
pass
|
|
|
|
# Compute and show bulk tags for this folder
|
|
def compute_folder_bulk_tags() -> str:
|
|
bulk_tag_ids = set()
|
|
# Gather saved bulk tags
|
|
for photo in folder_info.get('photos', []):
|
|
photo_id = photo['id']
|
|
saved_types = get_saved_tag_types_for_photo(photo_id)
|
|
for tid, ltype in saved_types.items():
|
|
if ltype == 1:
|
|
bulk_tag_ids.add(tid)
|
|
# Include pending bulk adds; exclude pending removals
|
|
for photo in folder_info.get('photos', []):
|
|
photo_id = photo['id']
|
|
for tid in pending_tag_changes.get(photo_id, []):
|
|
if pending_tag_linkage_type.get(photo_id, {}).get(tid) == 1:
|
|
if tid not in pending_tag_removals.get(photo_id, []):
|
|
bulk_tag_ids.add(tid)
|
|
# Exclude any saved bulk tags marked for removal
|
|
for tid in pending_tag_removals.get(photo_id, []):
|
|
if tid in bulk_tag_ids:
|
|
bulk_tag_ids.discard(tid)
|
|
names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in sorted(bulk_tag_ids, key=lambda x: tag_id_to_name.get(x, ""))]
|
|
return ", ".join(names) if names else "None"
|
|
|
|
# Append bulk tags to the folder label so it's clearly visible
|
|
try:
|
|
tags_str = compute_folder_bulk_tags()
|
|
if tags_str and tags_str != "None":
|
|
folder_label.configure(text=f"📁 {folder_info['folder_name']} ({folder_info['photo_count']} photos) — Tags: {tags_str}")
|
|
else:
|
|
folder_label.configure(text=f"📁 {folder_info['folder_name']} ({folder_info['photo_count']} photos)")
|
|
except Exception:
|
|
pass
|
|
|
|
return folder_header_frame
|
|
|
|
def toggle_folder(folder_path, view_mode):
|
|
folder_states[folder_path] = not folder_states.get(folder_path, True)
|
|
switch_view_mode(view_mode)
|
|
|
|
def load_existing_tags():
|
|
nonlocal existing_tags, tag_id_to_name, tag_name_to_id
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id, tag_name FROM tags ORDER BY tag_name')
|
|
existing_tags = []
|
|
tag_id_to_name = {}
|
|
tag_name_to_id = {}
|
|
for row in cursor.fetchall():
|
|
tag_id, tag_name = row
|
|
existing_tags.append(tag_name)
|
|
tag_id_to_name[tag_id] = tag_name
|
|
tag_name_to_id[tag_name] = tag_id
|
|
|
|
def create_tagging_widget(parent, photo_id, current_tags=""):
|
|
tag_var = tk.StringVar()
|
|
tagging_frame = ttk.Frame(parent)
|
|
tag_combo = ttk.Combobox(tagging_frame, textvariable=tag_var, width=12)
|
|
tag_combo['values'] = existing_tags
|
|
tag_combo.pack(side=tk.LEFT, padx=2, pady=2)
|
|
pending_tags_var = tk.StringVar()
|
|
ttk.Label(tagging_frame, textvariable=pending_tags_var, font=("Arial", 8), foreground="blue", width=20).pack(side=tk.LEFT, padx=2, pady=2)
|
|
if photo_id in pending_tag_changes:
|
|
pending_tag_names = [tag_id_to_name.get(tag_id, f"Unknown {tag_id}") for tag_id in pending_tag_changes[photo_id]]
|
|
pending_tags_var.set(", ".join(pending_tag_names))
|
|
else:
|
|
pending_tags_var.set(current_tags or "")
|
|
|
|
def add_tag():
|
|
tag_name = tag_var.get().strip()
|
|
if not tag_name:
|
|
return
|
|
if tag_name in tag_name_to_id:
|
|
tag_id = tag_name_to_id[tag_name]
|
|
else:
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,))
|
|
cursor.execute('SELECT id FROM tags WHERE tag_name = ?', (tag_name,))
|
|
tag_id = cursor.fetchone()[0]
|
|
tag_name_to_id[tag_name] = tag_id
|
|
tag_id_to_name[tag_id] = tag_name
|
|
if tag_name not in existing_tags:
|
|
existing_tags.append(tag_name)
|
|
existing_tags.sort()
|
|
existing_tag_ids = self.db.get_existing_tag_ids_for_photo(photo_id)
|
|
pending_tag_ids = pending_tag_changes.get(photo_id, [])
|
|
all_existing_tag_ids = existing_tag_ids + pending_tag_ids
|
|
if tag_id not in all_existing_tag_ids:
|
|
if photo_id not in pending_tag_changes:
|
|
pending_tag_changes[photo_id] = []
|
|
pending_tag_changes[photo_id].append(tag_id)
|
|
# record linkage type as single
|
|
if photo_id not in pending_tag_linkage_type:
|
|
pending_tag_linkage_type[photo_id] = {}
|
|
pending_tag_linkage_type[photo_id][tag_id] = 0
|
|
pending_tags_var.set(
|
|
", ".join([tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]])
|
|
)
|
|
tag_var.set("")
|
|
|
|
tk.Button(tagging_frame, text="+", width=2, height=1, command=add_tag).pack(side=tk.LEFT, padx=2, pady=2)
|
|
|
|
def remove_tag():
|
|
if photo_id in pending_tag_changes and pending_tag_changes[photo_id]:
|
|
removed_id = pending_tag_changes[photo_id].pop()
|
|
try:
|
|
if photo_id in pending_tag_linkage_type and removed_id in pending_tag_linkage_type[photo_id]:
|
|
del pending_tag_linkage_type[photo_id][removed_id]
|
|
if not pending_tag_linkage_type[photo_id]:
|
|
del pending_tag_linkage_type[photo_id]
|
|
except Exception:
|
|
pass
|
|
if pending_tag_changes[photo_id]:
|
|
pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]]
|
|
pending_tags_var.set(", ".join(pending_tag_names))
|
|
else:
|
|
pending_tags_var.set("")
|
|
del pending_tag_changes[photo_id]
|
|
|
|
tk.Button(tagging_frame, text="-", width=2, height=1, command=remove_tag).pack(side=tk.LEFT, padx=2, pady=2)
|
|
return tagging_frame
|
|
|
|
def save_tagging_changes():
|
|
if not pending_tag_changes and not pending_tag_removals:
|
|
messagebox.showinfo("Info", "No tag changes to save.")
|
|
return
|
|
try:
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
for photo_id, tag_ids in pending_tag_changes.items():
|
|
for tag_id in tag_ids:
|
|
lt = 0
|
|
try:
|
|
lt = pending_tag_linkage_type.get(photo_id, {}).get(tag_id, 0)
|
|
except Exception:
|
|
lt = 0
|
|
cursor.execute('''
|
|
INSERT INTO phototaglinkage (photo_id, tag_id, linkage_type)
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT(photo_id, tag_id) DO UPDATE SET linkage_type=excluded.linkage_type, created_date=CURRENT_TIMESTAMP
|
|
''', (photo_id, tag_id, lt))
|
|
for photo_id, tag_ids in pending_tag_removals.items():
|
|
for tag_id in tag_ids:
|
|
cursor.execute('DELETE FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', (photo_id, tag_id))
|
|
conn.commit()
|
|
saved_additions = len(pending_tag_changes)
|
|
saved_removals = len(pending_tag_removals)
|
|
pending_tag_changes.clear()
|
|
pending_tag_removals.clear()
|
|
pending_tag_linkage_type.clear()
|
|
load_existing_tags()
|
|
load_photos()
|
|
switch_view_mode(view_mode_var.get())
|
|
update_save_button_text()
|
|
message = f"Saved {saved_additions} tag additions"
|
|
if saved_removals > 0:
|
|
message += f" and {saved_removals} tag removals"
|
|
message += "."
|
|
messagebox.showinfo("Success", message)
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"Failed to save tags: {str(e)}")
|
|
|
|
def update_save_button_text():
|
|
total_additions = sum(len(tags) for tags in pending_tag_changes.values())
|
|
total_removals = sum(len(tags) for tags in pending_tag_removals.values())
|
|
total_changes = total_additions + total_removals
|
|
if total_changes > 0:
|
|
save_button.configure(text=f"Save Tagging ({total_changes} pending)")
|
|
else:
|
|
save_button.configure(text="Save Tagging")
|
|
|
|
save_button.configure(command=save_tagging_changes)
|
|
|
|
def clear_content():
|
|
for widget in content_inner.winfo_children():
|
|
widget.destroy()
|
|
for crop in list(temp_crops):
|
|
try:
|
|
if os.path.exists(crop):
|
|
os.remove(crop)
|
|
except Exception:
|
|
pass
|
|
temp_crops.clear()
|
|
photo_images.clear()
|
|
|
|
def show_column_context_menu(event, view_mode):
|
|
popup = tk.Toplevel(root)
|
|
popup.wm_overrideredirect(True)
|
|
popup.wm_geometry(f"+{event.x_root}+{event.y_root}")
|
|
popup.configure(bg='white', relief='flat', bd=0)
|
|
menu_frame = tk.Frame(popup, bg='white')
|
|
menu_frame.pack(padx=2, pady=2)
|
|
checkbox_vars: Dict[str, tk.BooleanVar] = {}
|
|
protected_columns = {'icons': ['thumbnail'], 'compact': ['filename'], 'list': ['filename']}
|
|
|
|
def close_popup():
|
|
try:
|
|
popup.destroy()
|
|
except Exception:
|
|
pass
|
|
|
|
def close_on_click_outside(e):
|
|
if e.widget != popup:
|
|
try:
|
|
popup.winfo_exists()
|
|
close_popup()
|
|
except tk.TclError:
|
|
pass
|
|
|
|
for col in column_config[view_mode]:
|
|
key = col['key']
|
|
label = col['label']
|
|
is_visible = column_visibility[view_mode][key]
|
|
is_protected = key in protected_columns.get(view_mode, [])
|
|
item_frame = tk.Frame(menu_frame, bg='white', relief='flat', bd=0)
|
|
item_frame.pack(fill=tk.X, pady=1)
|
|
var = tk.BooleanVar(value=is_visible)
|
|
checkbox_vars[key] = var
|
|
|
|
def make_toggle_command(col_key, var_ref):
|
|
def toggle_column():
|
|
if col_key in protected_columns.get(view_mode, []):
|
|
return
|
|
column_visibility[view_mode][col_key] = var_ref.get()
|
|
switch_view_mode(view_mode)
|
|
return toggle_column
|
|
|
|
if is_protected:
|
|
cb = tk.Checkbutton(item_frame, text=label, variable=var, state='disabled', bg='white', fg='gray', font=("Arial", 9), relief='flat', bd=0, highlightthickness=0)
|
|
cb.pack(side=tk.LEFT, padx=5, pady=2)
|
|
tk.Label(item_frame, text="(always visible)", bg='white', fg='gray', font=("Arial", 8)).pack(side=tk.LEFT, padx=(0, 5))
|
|
else:
|
|
cb = tk.Checkbutton(item_frame, text=label, variable=var, command=make_toggle_command(key, var), bg='white', font=("Arial", 9), relief='flat', bd=0, highlightthickness=0)
|
|
cb.pack(side=tk.LEFT, padx=5, pady=2)
|
|
|
|
root.bind("<Button-1>", close_on_click_outside)
|
|
root.bind("<Button-3>", close_on_click_outside)
|
|
content_canvas.bind("<Button-1>", close_on_click_outside)
|
|
content_canvas.bind("<Button-3>", close_on_click_outside)
|
|
popup.focus_set()
|
|
|
|
def create_add_tag_handler(photo_id, label_widget, photo_tags, available_tags, allowed_delete_type: int = 0):
|
|
def handler():
|
|
popup = tk.Toplevel(root)
|
|
popup.title("Manage Photo Tags")
|
|
popup.transient(root)
|
|
popup.grab_set()
|
|
popup.geometry("500x400")
|
|
popup.resizable(True, True)
|
|
top_frame = ttk.Frame(popup, padding="8")
|
|
top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E))
|
|
list_frame = ttk.Frame(popup, padding="8")
|
|
list_frame.grid(row=1, column=0, sticky=(tk.N, tk.S, tk.W, tk.E))
|
|
bottom_frame = ttk.Frame(popup, padding="8")
|
|
bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E))
|
|
popup.columnconfigure(0, weight=1)
|
|
popup.rowconfigure(1, weight=1)
|
|
ttk.Label(top_frame, text="Add tag:").grid(row=0, column=0, padx=(0, 8), sticky=tk.W)
|
|
tag_var = tk.StringVar()
|
|
combo = ttk.Combobox(top_frame, textvariable=tag_var, values=available_tags, width=30, state='readonly')
|
|
combo.grid(row=0, column=1, padx=(0, 8), sticky=(tk.W, tk.E))
|
|
combo.focus_set()
|
|
|
|
def _update_tags_label_in_main_list():
|
|
# Recompute combined display for the main list label (saved + pending minus removals)
|
|
existing_tags_list = self.tag_manager.parse_tags_string(photo_tags)
|
|
pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes.get(photo_id, [])]
|
|
pending_removal_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_removals.get(photo_id, [])]
|
|
all_tags = existing_tags_list + pending_tag_names
|
|
unique_tags = self.tag_manager.deduplicate_tags(all_tags)
|
|
# Remove tags marked for removal from display
|
|
unique_tags = [tag for tag in unique_tags if tag not in pending_removal_names]
|
|
current_tags = ", ".join(unique_tags) if unique_tags else "None"
|
|
try:
|
|
label_widget.configure(text=current_tags)
|
|
except Exception:
|
|
pass
|
|
|
|
def add_selected_tag():
|
|
tag_name = tag_var.get().strip()
|
|
if not tag_name:
|
|
return
|
|
if tag_name in tag_name_to_id:
|
|
tag_id = tag_name_to_id[tag_name]
|
|
else:
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,))
|
|
cursor.execute('SELECT id FROM tags WHERE tag_name = ?', (tag_name,))
|
|
tag_id = cursor.fetchone()[0]
|
|
tag_name_to_id[tag_name] = tag_id
|
|
tag_id_to_name[tag_id] = tag_name
|
|
if tag_name not in existing_tags:
|
|
existing_tags.append(tag_name)
|
|
existing_tags.sort()
|
|
saved_types = get_saved_tag_types_for_photo(photo_id)
|
|
existing_tag_ids = list(saved_types.keys())
|
|
pending_tag_ids = pending_tag_changes.get(photo_id, [])
|
|
all_existing_tag_ids = existing_tag_ids + pending_tag_ids
|
|
if tag_id not in all_existing_tag_ids:
|
|
if photo_id not in pending_tag_changes:
|
|
pending_tag_changes[photo_id] = []
|
|
pending_tag_changes[photo_id].append(tag_id)
|
|
# mark pending type as single (0)
|
|
if photo_id not in pending_tag_linkage_type:
|
|
pending_tag_linkage_type[photo_id] = {}
|
|
pending_tag_linkage_type[photo_id][tag_id] = 0
|
|
refresh_tag_list()
|
|
update_save_button_text()
|
|
_update_tags_label_in_main_list()
|
|
tag_var.set("")
|
|
|
|
ttk.Button(top_frame, text="Add", command=add_selected_tag).grid(row=0, column=2, padx=(0, 8))
|
|
|
|
canvas = tk.Canvas(list_frame, height=200)
|
|
scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview)
|
|
scrollable_frame = ttk.Frame(canvas)
|
|
scrollable_frame.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
|
|
canvas.create_window((0, 0), window=scrollable_frame, anchor="nw")
|
|
canvas.configure(yscrollcommand=scrollbar.set)
|
|
canvas.pack(side="left", fill="both", expand=True)
|
|
scrollbar.pack(side="right", fill="y")
|
|
|
|
selected_tag_vars: Dict[str, tk.BooleanVar] = {}
|
|
|
|
def refresh_tag_list():
|
|
for widget in scrollable_frame.winfo_children():
|
|
widget.destroy()
|
|
selected_tag_vars.clear()
|
|
saved_types = get_saved_tag_types_for_photo(photo_id)
|
|
existing_tag_ids = list(saved_types.keys())
|
|
pending_tag_ids = pending_tag_changes.get(photo_id, [])
|
|
pending_removal_ids = pending_tag_removals.get(photo_id, [])
|
|
all_tag_ids = existing_tag_ids + pending_tag_ids
|
|
unique_tag_ids = list(set(all_tag_ids))
|
|
unique_tag_ids = [tid for tid in unique_tag_ids if tid not in pending_removal_ids]
|
|
unique_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in unique_tag_ids]
|
|
if not unique_tag_names:
|
|
ttk.Label(scrollable_frame, text="No tags linked to this photo", foreground="gray").pack(anchor=tk.W, pady=5)
|
|
return
|
|
for i, tag_id in enumerate(unique_tag_ids):
|
|
tag_name = tag_id_to_name.get(tag_id, f"Unknown {tag_id}")
|
|
var = tk.BooleanVar()
|
|
selected_tag_vars[tag_name] = var
|
|
frame = ttk.Frame(scrollable_frame)
|
|
frame.pack(fill=tk.X, pady=1)
|
|
is_pending = tag_id in pending_tag_ids
|
|
saved_type = saved_types.get(tag_id)
|
|
pending_type = pending_tag_linkage_type.get(photo_id, {}).get(tag_id)
|
|
# In single-photo dialog, only allow selecting pending if pending type is single (0)
|
|
is_pending_single = is_pending and pending_type == 0
|
|
can_select = is_pending_single or (saved_type == allowed_delete_type)
|
|
cb = ttk.Checkbutton(frame, variable=var)
|
|
if not can_select:
|
|
try:
|
|
cb.state(["disabled"]) # disable selection for disallowed types
|
|
except Exception:
|
|
pass
|
|
cb.pack(side=tk.LEFT, padx=(0, 5))
|
|
type_label = 'single' if saved_type == 0 else ('bulk' if saved_type == 1 else '?')
|
|
pending_label = "pending bulk" if (is_pending and pending_type == 1) else "pending"
|
|
status_text = f" ({pending_label})" if is_pending else f" (saved {type_label})"
|
|
status_color = "blue" if is_pending else ("black" if can_select else "gray")
|
|
ttk.Label(frame, text=tag_name + status_text, foreground=status_color).pack(side=tk.LEFT)
|
|
|
|
def remove_selected_tags():
|
|
tag_ids_to_remove = []
|
|
for tag_name, var in selected_tag_vars.items():
|
|
if var.get() and tag_name in tag_name_to_id:
|
|
tag_ids_to_remove.append(tag_name_to_id[tag_name])
|
|
if not tag_ids_to_remove:
|
|
return
|
|
if photo_id in pending_tag_changes:
|
|
pending_tag_changes[photo_id] = [tid for tid in pending_tag_changes[photo_id] if tid not in tag_ids_to_remove]
|
|
if not pending_tag_changes[photo_id]:
|
|
del pending_tag_changes[photo_id]
|
|
saved_types = get_saved_tag_types_for_photo(photo_id)
|
|
for tag_id in tag_ids_to_remove:
|
|
if saved_types.get(tag_id) == allowed_delete_type:
|
|
if photo_id not in pending_tag_removals:
|
|
pending_tag_removals[photo_id] = []
|
|
if tag_id not in pending_tag_removals[photo_id]:
|
|
pending_tag_removals[photo_id].append(tag_id)
|
|
refresh_tag_list()
|
|
update_save_button_text()
|
|
_update_tags_label_in_main_list()
|
|
|
|
ttk.Button(bottom_frame, text="Remove selected tags", command=remove_selected_tags).pack(side=tk.LEFT, padx=(0, 8))
|
|
ttk.Button(bottom_frame, text="Close", command=popup.destroy).pack(side=tk.RIGHT)
|
|
refresh_tag_list()
|
|
|
|
def on_close():
|
|
existing_tags_list = self.tag_manager.parse_tags_string(photo_tags)
|
|
pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes.get(photo_id, [])]
|
|
pending_removal_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_removals.get(photo_id, [])]
|
|
all_tags = existing_tags_list + pending_tag_names
|
|
unique_tags = self.tag_manager.deduplicate_tags(all_tags)
|
|
unique_tags = [tag for tag in unique_tags if tag not in pending_removal_names]
|
|
current_tags = ", ".join(unique_tags) if unique_tags else "None"
|
|
label_widget.configure(text=current_tags)
|
|
popup.destroy()
|
|
|
|
popup.protocol("WM_DELETE_WINDOW", on_close)
|
|
return handler
|
|
|
|
def create_tag_buttons_frame(parent, photo_id, photo_tags, existing_tags, use_grid=False, row=0, col=0):
|
|
tags_frame = ttk.Frame(parent)
|
|
existing_tags_list = self.tag_manager.parse_tags_string(photo_tags)
|
|
pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes.get(photo_id, [])]
|
|
pending_removal_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_removals.get(photo_id, [])]
|
|
all_tags = existing_tags_list + pending_tag_names
|
|
unique_tags = self.tag_manager.deduplicate_tags(all_tags)
|
|
unique_tags = [tag for tag in unique_tags if tag not in pending_removal_names]
|
|
current_display = ", ".join(unique_tags) if unique_tags else "None"
|
|
tags_text = ttk.Label(tags_frame, text=current_display)
|
|
tags_text.pack(side=tk.LEFT)
|
|
add_btn = tk.Button(tags_frame, text="🔗", width=2, command=create_add_tag_handler(photo_id, tags_text, photo_tags, existing_tags, 0))
|
|
add_btn.pack(side=tk.LEFT, padx=(6, 0))
|
|
try:
|
|
_ToolTip(add_btn, "Manage tags for this photo")
|
|
except Exception:
|
|
pass
|
|
if use_grid:
|
|
tags_frame.grid(row=row, column=col, padx=5, sticky=tk.W)
|
|
else:
|
|
tags_frame.pack(side=tk.LEFT, padx=5)
|
|
return tags_frame
|
|
|
|
def show_list_view():
|
|
clear_content()
|
|
nonlocal current_visible_cols
|
|
current_visible_cols = [col.copy() for col in column_config['list'] if column_visibility['list'][col['key']]]
|
|
col_count = len(current_visible_cols)
|
|
if col_count == 0:
|
|
ttk.Label(content_inner, text="No columns visible. Right-click to show columns.", font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20)
|
|
return
|
|
for i, col in enumerate(current_visible_cols):
|
|
content_inner.columnconfigure(i, weight=col['weight'], minsize=col['width'])
|
|
header_frame = ttk.Frame(content_inner)
|
|
header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5))
|
|
for i, col in enumerate(current_visible_cols):
|
|
header_frame.columnconfigure(i * 2, weight=col['weight'], minsize=col['width'])
|
|
if i < len(current_visible_cols) - 1:
|
|
header_frame.columnconfigure(i * 2 + 1, weight=0, minsize=1)
|
|
for i, col in enumerate(current_visible_cols):
|
|
header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold"))
|
|
header_label.grid(row=0, column=i * 2, padx=5, sticky=tk.W)
|
|
header_label.bind("<Button-3>", lambda e, mode='list': show_column_context_menu(e, mode))
|
|
if i < len(current_visible_cols) - 1:
|
|
separator_frame = tk.Frame(header_frame, width=10, bg='red', cursor="sb_h_double_arrow")
|
|
separator_frame.grid(row=0, column=i * 2 + 1, sticky='ns', padx=0)
|
|
separator_frame.grid_propagate(False)
|
|
inner_line = tk.Frame(separator_frame, bg='darkred', width=2)
|
|
inner_line.pack(fill=tk.Y, expand=True)
|
|
separator_frame.bind("<Button-1>", lambda e, col_idx=i: start_resize(e, col_idx))
|
|
separator_frame.bind("<B1-Motion>", lambda e, col_idx=i: do_resize(e, col_idx))
|
|
separator_frame.bind("<ButtonRelease-1>", stop_resize)
|
|
separator_frame.bind("<Enter>", lambda e, f=separator_frame, l=inner_line: (f.configure(bg='orange'), l.configure(bg='darkorange')))
|
|
separator_frame.bind("<Leave>", lambda e, f=separator_frame, l=inner_line: (f.configure(bg='red'), l.configure(bg='darkred')))
|
|
inner_line.bind("<Button-1>", lambda e, col_idx=i: start_resize(e, col_idx))
|
|
inner_line.bind("<B1-Motion>", lambda e, col_idx=i: do_resize(e, col_idx))
|
|
inner_line.bind("<ButtonRelease-1>", stop_resize)
|
|
header_frame.bind("<Button-3>", lambda e, mode='list': show_column_context_menu(e, mode))
|
|
ttk.Separator(content_inner, orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5))
|
|
folder_data = prepare_folder_grouped_data()
|
|
current_row = 2
|
|
for folder_info in folder_data:
|
|
create_folder_header(content_inner, folder_info, current_row, col_count, 'list')
|
|
current_row += 1
|
|
if folder_states.get(folder_info['folder_path'], True):
|
|
for photo in folder_info['photos']:
|
|
row_frame = ttk.Frame(content_inner)
|
|
row_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=1)
|
|
for i, col in enumerate(current_visible_cols):
|
|
row_frame.columnconfigure(i, weight=col['weight'], minsize=col['width'])
|
|
for i, col in enumerate(current_visible_cols):
|
|
key = col['key']
|
|
if key == 'id':
|
|
text = str(photo['id'])
|
|
elif key == 'filename':
|
|
text = photo['filename']
|
|
elif key == 'path':
|
|
text = photo['path']
|
|
elif key == 'processed':
|
|
text = "Yes" if photo['processed'] else "No"
|
|
elif key == 'date_taken':
|
|
text = photo['date_taken'] or "Unknown"
|
|
elif key == 'faces':
|
|
text = str(photo['face_count'])
|
|
elif key == 'tags':
|
|
create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], existing_tags, use_grid=True, row=0, col=i)
|
|
continue
|
|
# Render text wrapped to header width; do not auto-resize columns
|
|
if key == 'filename':
|
|
lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", wraplength=col['width'], anchor='w', justify='left')
|
|
lbl.grid(row=0, column=i, padx=5, sticky=tk.W)
|
|
lbl.bind("<Button-1>", lambda e, p=photo['path']: open_photo(p))
|
|
elif key == 'faces' and photo['face_count'] > 0:
|
|
lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", wraplength=col['width'], anchor='w', justify='left')
|
|
lbl.grid(row=0, column=i, padx=5, sticky=tk.W)
|
|
lbl.bind("<Button-1>", lambda e, pid=photo['id'], fname=photo['filename']: show_people_names_popup(pid, fname))
|
|
try:
|
|
_ToolTip(lbl, "Click to see people in this photo")
|
|
except Exception:
|
|
pass
|
|
else:
|
|
ttk.Label(row_frame, text=text, wraplength=col['width'], anchor='w', justify='left').grid(row=0, column=i, padx=5, sticky=tk.W)
|
|
current_row += 1
|
|
|
|
def show_icon_view():
|
|
clear_content()
|
|
visible_cols = [col for col in column_config['icons'] if column_visibility['icons'][col['key']]]
|
|
col_count = len(visible_cols)
|
|
if col_count == 0:
|
|
ttk.Label(content_inner, text="No columns visible. Right-click to show columns.", font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20)
|
|
return
|
|
for i, col in enumerate(visible_cols):
|
|
content_inner.columnconfigure(i, weight=col['weight'], minsize=col['width'])
|
|
header_frame = ttk.Frame(content_inner)
|
|
header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5))
|
|
for i, col in enumerate(visible_cols):
|
|
header_frame.columnconfigure(i, weight=col['weight'], minsize=col['width'])
|
|
for i, col in enumerate(visible_cols):
|
|
header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold"))
|
|
header_label.grid(row=0, column=i, padx=5, sticky=tk.W)
|
|
header_label.bind("<Button-3>", lambda e, mode='icons': show_column_context_menu(e, mode))
|
|
header_frame.bind("<Button-3>", lambda e, mode='icons': show_column_context_menu(e, mode))
|
|
ttk.Separator(content_inner, orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5))
|
|
folder_data = prepare_folder_grouped_data()
|
|
current_row = 2
|
|
for folder_info in folder_data:
|
|
create_folder_header(content_inner, folder_info, current_row, col_count, 'icons')
|
|
current_row += 1
|
|
if folder_states.get(folder_info['folder_path'], True):
|
|
for photo in folder_info['photos']:
|
|
row_frame = ttk.Frame(content_inner)
|
|
row_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=2)
|
|
for i, col in enumerate(visible_cols):
|
|
row_frame.columnconfigure(i, weight=col['weight'], minsize=col['width'])
|
|
col_idx = 0
|
|
for col in visible_cols:
|
|
key = col['key']
|
|
if key == 'thumbnail':
|
|
thumbnail_frame = ttk.Frame(row_frame)
|
|
thumbnail_frame.grid(row=0, column=col_idx, padx=5, sticky=tk.W)
|
|
try:
|
|
if os.path.exists(photo['path']):
|
|
img = Image.open(photo['path'])
|
|
img.thumbnail((150, 150), Image.Resampling.LANCZOS)
|
|
photo_img = ImageTk.PhotoImage(img)
|
|
photo_images.append(photo_img)
|
|
canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg=canvas_bg_color, highlightthickness=0)
|
|
canvas.pack()
|
|
canvas.create_image(75, 75, image=photo_img)
|
|
else:
|
|
canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg=canvas_bg_color, highlightthickness=0)
|
|
canvas.pack()
|
|
canvas.create_text(75, 75, text="🖼️", fill="gray", font=("Arial", 24))
|
|
except Exception:
|
|
canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg=canvas_bg_color, highlightthickness=0)
|
|
canvas.pack()
|
|
canvas.create_text(75, 75, text="❌", fill="red", font=("Arial", 24))
|
|
else:
|
|
if key == 'id':
|
|
text = str(photo['id'])
|
|
elif key == 'filename':
|
|
text = photo['filename']
|
|
elif key == 'processed':
|
|
text = "Yes" if photo['processed'] else "No"
|
|
elif key == 'date_taken':
|
|
text = photo['date_taken'] or "Unknown"
|
|
elif key == 'faces':
|
|
text = str(photo['face_count'])
|
|
elif key == 'tags':
|
|
create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], existing_tags, use_grid=True, row=0, col=col_idx)
|
|
col_idx += 1
|
|
continue
|
|
# Render text wrapped to header width; do not auto-resize columns
|
|
if key == 'filename':
|
|
lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", wraplength=col['width'], anchor='w', justify='left')
|
|
lbl.grid(row=0, column=col_idx, padx=5, sticky=tk.W)
|
|
lbl.bind("<Button-1>", lambda e, p=photo['path']: open_photo(p))
|
|
elif key == 'faces' and photo['face_count'] > 0:
|
|
lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", wraplength=col['width'], anchor='w', justify='left')
|
|
lbl.grid(row=0, column=col_idx, padx=5, sticky=tk.W)
|
|
lbl.bind("<Button-1>", lambda e, pid=photo['id'], fname=photo['filename']: show_people_names_popup(pid, fname))
|
|
try:
|
|
_ToolTip(lbl, "Click to see people in this photo")
|
|
except Exception:
|
|
pass
|
|
else:
|
|
ttk.Label(row_frame, text=text, wraplength=col['width'], anchor='w', justify='left').grid(row=0, column=col_idx, padx=5, sticky=tk.W)
|
|
col_idx += 1
|
|
current_row += 1
|
|
|
|
def show_compact_view():
|
|
clear_content()
|
|
visible_cols = [col for col in column_config['compact'] if column_visibility['compact'][col['key']]]
|
|
col_count = len(visible_cols)
|
|
if col_count == 0:
|
|
ttk.Label(content_inner, text="No columns visible. Right-click to show columns.", font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20)
|
|
return
|
|
for i, col in enumerate(visible_cols):
|
|
content_inner.columnconfigure(i, weight=col['weight'], minsize=col['width'])
|
|
header_frame = ttk.Frame(content_inner)
|
|
header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5))
|
|
for i, col in enumerate(visible_cols):
|
|
header_frame.columnconfigure(i, weight=col['weight'], minsize=col['width'])
|
|
for i, col in enumerate(visible_cols):
|
|
header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold"))
|
|
header_label.grid(row=0, column=i, padx=5, sticky=tk.W)
|
|
header_label.bind("<Button-3>", lambda e, mode='compact': show_column_context_menu(e, mode))
|
|
header_frame.bind("<Button-3>", lambda e, mode='compact': show_column_context_menu(e, mode))
|
|
ttk.Separator(content_inner, orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5))
|
|
folder_data = prepare_folder_grouped_data()
|
|
current_row = 2
|
|
for folder_info in folder_data:
|
|
create_folder_header(content_inner, folder_info, current_row, col_count, 'compact')
|
|
current_row += 1
|
|
if folder_states.get(folder_info['folder_path'], True):
|
|
for photo in folder_info['photos']:
|
|
row_frame = ttk.Frame(content_inner)
|
|
row_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=1)
|
|
for i, col in enumerate(visible_cols):
|
|
row_frame.columnconfigure(i, weight=col['weight'], minsize=col['width'])
|
|
col_idx = 0
|
|
for col in visible_cols:
|
|
key = col['key']
|
|
if key == 'filename':
|
|
text = photo['filename']
|
|
elif key == 'faces':
|
|
text = str(photo['face_count'])
|
|
elif key == 'tags':
|
|
create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], existing_tags, use_grid=True, row=0, col=col_idx)
|
|
col_idx += 1
|
|
continue
|
|
# Render text wrapped to header width; do not auto-resize columns
|
|
if key == 'filename':
|
|
lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", wraplength=col['width'], anchor='w', justify='left')
|
|
lbl.grid(row=0, column=col_idx, padx=5, sticky=tk.W)
|
|
lbl.bind("<Button-1>", lambda e, p=photo['path']: open_photo(p))
|
|
elif key == 'faces' and photo['face_count'] > 0:
|
|
lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", wraplength=col['width'], anchor='w', justify='left')
|
|
lbl.grid(row=0, column=col_idx, padx=5, sticky=tk.W)
|
|
lbl.bind("<Button-1>", lambda e, pid=photo['id'], fname=photo['filename']: show_people_names_popup(pid, fname))
|
|
try:
|
|
_ToolTip(lbl, "Click to see people in this photo")
|
|
except Exception:
|
|
pass
|
|
else:
|
|
ttk.Label(row_frame, text=text, wraplength=col['width'], anchor='w', justify='left').grid(row=0, column=col_idx, padx=5, sticky=tk.W)
|
|
col_idx += 1
|
|
current_row += 1
|
|
|
|
def switch_view_mode(mode):
|
|
if mode == "list":
|
|
show_list_view()
|
|
elif mode == "icons":
|
|
show_icon_view()
|
|
elif mode == "compact":
|
|
show_compact_view()
|
|
|
|
load_existing_tags()
|
|
load_photos()
|
|
show_list_view()
|
|
root.deiconify()
|
|
root.mainloop()
|
|
return 0
|
|
|
|
|