Enhance TagManagerGUI with bulk tagging features and linkage type management

This commit introduces significant enhancements to the TagManagerGUI, including the ability to add and manage bulk tags for all photos in a folder. Users can now link tags in bulk, with clear distinctions between single and bulk linkage types. The GUI also features improved handling of pending tag changes, allowing for better tracking and management of tag states. Additionally, the README has been updated to reflect these new functionalities and provide usage instructions.
This commit is contained in:
tanyar09 2025-10-06 14:36:19 -04:00
parent 64c29f24de
commit 01404418f7
2 changed files with 372 additions and 16 deletions

View File

@ -200,6 +200,7 @@ This GUI provides a file explorer-like interface for managing photo tags with ad
- ✅ **Pending Tag System** - Add and remove tags with pending changes until saved
- 🎯 **Visual Status Indicators** - Clear distinction between saved and pending tags
- 🗑️ **Smart Tag Removal** - Remove both pending and saved tags with proper tracking
- 🧩 **Linkage Types (Single vs Bulk)** - Tag links can be added per-photo (single) or for the entire folder (bulk). Bulk links appear on folder headers and follow special rules in dialogs
**📋 Available View Modes:**
@ -224,6 +225,7 @@ Folder grouping applies across all views:
- 📁 **Directory Grouping** - Photos grouped by their directory path
- 🔽 **Expandable Folders** - Click folder headers to expand/collapse
- 📊 **Photo Counts** - Shows number of photos in each folder
- 🏷️ **Folder Bulk Tags** - Folder header shows bulk tags that apply to all photos in that folder (includes pending bulk adds not marked for removal)
**🔧 Column Resizing:**
- 🖱️ **Drag to Resize** - Click and drag red separators between columns
@ -256,6 +258,8 @@ Folder grouping applies across all views:
- 📊 **Batch Operations** - Select multiple tags for removal with checkboxes
- 🔄 **Real-time Updates** - Tag display updates immediately when changes are made
- 💾 **Save System** - All tag changes (additions and removals) saved atomically when "Save Tagging" is clicked
- 🔁 **Bulk Overrides Single** - If a tag was previously added as single to some photos, adding the same tag in bulk for the folder upgrades those single links to bulk on save
- 🚫 **Scoped Deletions** - Single-photo tag dialog can delete saved/pending single links only; Bulk dialog deletes saved bulk links or cancels pending bulk adds only
- 🎯 **ID-Based Architecture** - Uses tag IDs internally for efficient, reliable operations
- ⚡ **Performance Optimized** - Fast tag operations with minimal database queries
@ -402,6 +406,7 @@ The tool uses SQLite database (`data/photos.db` by default) with these tables:
- **faces** - Face encodings, locations, and quality scores
- **tags** - Tag definitions (unique tag names)
- **phototaglinkage** - Links between photos and tags (many-to-many relationship)
- Columns: `linkage_id` (PK), `photo_id`, `tag_id`, `linkage_type` (INTEGER: 0=single, 1=bulk), `created_date`
- **person_encodings** - Face encodings for each person (for matching)
### Database Schema Improvements
@ -451,6 +456,7 @@ sudo apt install -y cmake build-essential libopenblas-dev liblapack-dev libx11-d
PunimTag/
├── photo_tagger.py # Main CLI tool
├── setup.py # Setup script
├── run.sh # Convenience script (auto-activates venv)
├── requirements.txt # Python dependencies
├── README.md # This file
├── gui_config.json # GUI window size preferences (created automatically)
@ -893,7 +899,16 @@ This is now a minimal, focused tool. Key principles:
# Setup (one time)
python3 -m venv venv && source venv/bin/activate && python3 setup.py
# Daily usage - Manual venv activation (GUI-ENHANCED)
# Daily usage - Option 1: Use run script (automatic venv activation)
./run.sh scan ~/Pictures --recursive
./run.sh process --limit 50
./run.sh identify --show-faces --batch 10
./run.sh auto-match --show-faces
./run.sh modifyidentified
./run.sh tag-manager
./run.sh stats
# Daily usage - Option 2: Manual venv activation (GUI-ENHANCED)
source venv/bin/activate
python3 photo_tagger.py scan ~/Pictures --recursive
python3 photo_tagger.py process --limit 50

View File

@ -40,6 +40,24 @@ class TagManagerGUI:
# 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] = {}
@ -47,6 +65,39 @@ class TagManagerGUI:
# 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
@ -434,7 +485,8 @@ class TagManagerGUI:
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:
folder_states[folder_path] = True
# Collapse folders by default on first load
folder_states[folder_path] = False
sorted_folders.append({
'folder_path': folder_path,
'folder_name': folder_name,
@ -447,14 +499,239 @@ class TagManagerGUI:
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):
@ -513,6 +790,10 @@ class TagManagerGUI:
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]])
)
@ -522,7 +803,14 @@ class TagManagerGUI:
def remove_tag():
if photo_id in pending_tag_changes and pending_tag_changes[photo_id]:
pending_tag_changes[photo_id].pop()
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))
@ -542,7 +830,16 @@ class TagManagerGUI:
cursor = conn.cursor()
for photo_id, tag_ids in pending_tag_changes.items():
for tag_id in tag_ids:
cursor.execute('INSERT OR IGNORE INTO phototaglinkage (photo_id, tag_id) VALUES (?, ?)', (photo_id, tag_id))
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))
@ -551,6 +848,7 @@ class TagManagerGUI:
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())
@ -642,7 +940,7 @@ class TagManagerGUI:
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):
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")
@ -664,6 +962,21 @@ class TagManagerGUI:
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:
@ -681,15 +994,21 @@ class TagManagerGUI:
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)
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))
@ -709,7 +1028,8 @@ class TagManagerGUI:
for widget in scrollable_frame.winfo_children():
widget.destroy()
selected_tag_vars.clear()
existing_tag_ids = self.db.get_existing_tag_ids_for_photo(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, [])
pending_removal_ids = pending_tag_removals.get(photo_id, [])
all_tag_ids = existing_tag_ids + pending_tag_ids
@ -725,10 +1045,23 @@ class TagManagerGUI:
selected_tag_vars[tag_name] = var
frame = ttk.Frame(scrollable_frame)
frame.pack(fill=tk.X, pady=1)
ttk.Checkbutton(frame, variable=var).pack(side=tk.LEFT, padx=(0, 5))
is_pending = tag_id in pending_tag_ids
status_text = " (pending)" if is_pending else " (saved)"
status_color = "blue" if is_pending else "black"
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():
@ -742,15 +1075,16 @@ class TagManagerGUI:
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]
existing_tag_ids = self.db.get_existing_tag_ids_for_photo(photo_id)
saved_types = get_saved_tag_types_for_photo(photo_id)
for tag_id in tag_ids_to_remove:
if tag_id in existing_tag_ids:
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)
@ -781,8 +1115,12 @@ class TagManagerGUI:
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))
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:
@ -853,7 +1191,8 @@ class TagManagerGUI:
elif key == 'tags':
create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], existing_tags, use_grid=True, row=0, col=i)
continue
ttk.Label(row_frame, text=text).grid(row=0, column=i, padx=5, sticky=tk.W)
# Render text wrapped to header width; do not auto-resize columns
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():
@ -924,7 +1263,8 @@ class TagManagerGUI:
create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], existing_tags, use_grid=True, row=0, col=col_idx)
col_idx += 1
continue
ttk.Label(row_frame, text=text).grid(row=0, column=col_idx, padx=5, sticky=tk.W)
# Render text wrapped to header width; do not auto-resize columns
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
@ -969,7 +1309,8 @@ class TagManagerGUI:
create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], existing_tags, use_grid=True, row=0, col=col_idx)
col_idx += 1
continue
ttk.Label(row_frame, text=text).grid(row=0, column=col_idx, padx=5, sticky=tk.W)
# Render text wrapped to header width; do not auto-resize columns
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