diff --git a/README.md b/README.md index c5a2e70..73ca342 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/tag_manager_gui.py b/tag_manager_gui.py index 679493e..5855fc9 100644 --- a/tag_manager_gui.py +++ b/tag_manager_gui.py @@ -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("", self._on) + widget.bind("", 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("", 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("", 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