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:
parent
64c29f24de
commit
01404418f7
17
README.md
17
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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user