punimtag/tag_management.py
tanyar09 40ffc0692e Enhance database and GUI for case-insensitive tag management
This commit updates the DatabaseManager to support case-insensitive tag lookups and additions, ensuring consistent tag handling. The SearchGUI and TagManagerGUI have been modified to reflect these changes, allowing for improved user experience when managing tags. Additionally, the search logic in SearchStats and TagManagement has been adjusted for case-insensitive tag ID retrieval, enhancing overall functionality and reliability in tag management across the application.
2025-10-08 14:03:41 -04:00

267 lines
9.3 KiB
Python

#!/usr/bin/env python3
"""
Tag management functionality for PunimTag
"""
from typing import List, Dict, Tuple, Optional
from config import DEFAULT_BATCH_SIZE
from database import DatabaseManager
class TagManager:
"""Handles photo tagging and tag management operations"""
def __init__(self, db_manager: DatabaseManager, verbose: int = 0):
"""Initialize tag manager"""
self.db = db_manager
self.verbose = verbose
def deduplicate_tags(self, tag_list: List[str]) -> List[str]:
"""Remove duplicate tags from a list while preserving order (case insensitive)"""
seen = set()
unique_tags = []
for tag in tag_list:
if tag.lower() not in seen:
seen.add(tag.lower())
unique_tags.append(tag)
return unique_tags
def parse_tags_string(self, tags_string: str) -> List[str]:
"""Parse a comma-separated tags string into a list, handling empty strings and whitespace"""
if not tags_string or tags_string.strip() == "":
return []
# Split by comma and strip whitespace from each tag
tags = [tag.strip() for tag in tags_string.split(",")]
# Remove empty strings that might result from splitting
return [tag for tag in tags if tag]
def add_tags_to_photos(self, photo_pattern: str = None, batch_size: int = DEFAULT_BATCH_SIZE) -> int:
"""Add custom tags to photos via command line interface"""
if photo_pattern:
photos = self.db.get_photos_by_pattern(photo_pattern, batch_size)
else:
photos = self.db.get_photos_by_pattern(limit=batch_size)
if not photos:
print("No photos found")
return 0
print(f"🏷️ Tagging {len(photos)} photos (enter comma-separated tags)")
tagged_count = 0
for photo_id, photo_path, filename, date_taken, processed in photos:
print(f"\n📸 {filename}")
tags_input = input("🏷️ Tags: ").strip()
if tags_input.lower() == 'q':
break
if tags_input:
tags = self.parse_tags_string(tags_input)
tags = self.deduplicate_tags(tags)
for tag_name in tags:
# Add tag to database and get its ID
tag_id = self.db.add_tag(tag_name)
if tag_id:
# Link photo to tag
self.db.link_photo_tag(photo_id, tag_id)
print(f" ✅ Added {len(tags)} tags")
tagged_count += 1
print(f"✅ Tagged {tagged_count} photos")
return tagged_count
def add_tags_to_photo(self, photo_id: int, tags: List[str]) -> int:
"""Add tags to a specific photo"""
if not tags:
return 0
tags = self.deduplicate_tags(tags)
added_count = 0
for tag_name in tags:
# Add tag to database and get its ID
tag_id = self.db.add_tag(tag_name)
if tag_id:
# Link photo to tag
self.db.link_photo_tag(photo_id, tag_id)
added_count += 1
return added_count
def remove_tags_from_photo(self, photo_id: int, tags: List[str]) -> int:
"""Remove tags from a specific photo"""
if not tags:
return 0
removed_count = 0
tag_id_to_name, tag_name_to_id = self.db.load_tag_mappings()
for tag_name in tags:
if tag_name in tag_name_to_id:
tag_id = tag_name_to_id[tag_name]
self.db.unlink_photo_tag(photo_id, tag_id)
removed_count += 1
return removed_count
def get_photo_tags(self, photo_id: int) -> List[str]:
"""Get all tags for a specific photo"""
tag_ids = self.db.get_existing_tag_ids_for_photo(photo_id)
tag_id_to_name, _ = self.db.load_tag_mappings()
tags = []
for tag_id in tag_ids:
tag_name = self.db.get_tag_name_by_id(tag_id, tag_id_to_name)
tags.append(tag_name)
return tags
def get_all_tags(self) -> List[Tuple[int, str]]:
"""Get all tags in the database"""
tag_id_to_name, _ = self.db.load_tag_mappings()
return [(tag_id, tag_name) for tag_id, tag_name in tag_id_to_name.items()]
def get_photos_with_tag(self, tag_name: str) -> List[Tuple]:
"""Get all photos that have a specific tag"""
tag_id_to_name, tag_name_to_id = self.db.load_tag_mappings()
if tag_name not in tag_name_to_id:
return []
tag_id = tag_name_to_id[tag_name]
# This would need to be implemented in the database module
# For now, return empty list
return []
def get_tag_statistics(self) -> Dict:
"""Get tag usage statistics"""
tag_id_to_name, _ = self.db.load_tag_mappings()
stats = {
'total_tags': len(tag_id_to_name),
'tag_usage': {}
}
# Count usage for each tag
for tag_id, tag_name in tag_id_to_name.items():
# This would need to be implemented in the database module
# For now, set usage to 0
stats['tag_usage'][tag_name] = 0
return stats
def delete_tag(self, tag_name: str) -> bool:
"""Delete a tag from the database (and all its linkages)"""
tag_id_to_name, tag_name_to_id = self.db.load_tag_mappings()
if tag_name not in tag_name_to_id:
return False
tag_id = tag_name_to_id[tag_name]
# This would need to be implemented in the database module
# For now, return False
return False
def rename_tag(self, old_name: str, new_name: str) -> bool:
"""Rename a tag"""
tag_id_to_name, tag_name_to_id = self.db.load_tag_mappings()
if old_name not in tag_name_to_id:
return False
if new_name in tag_name_to_id:
return False # New name already exists
tag_id = tag_name_to_id[old_name]
# This would need to be implemented in the database module
# For now, return False
return False
def merge_tags(self, source_tag: str, target_tag: str) -> bool:
"""Merge one tag into another (move all linkages from source to target)"""
tag_id_to_name, tag_name_to_id = self.db.load_tag_mappings()
if source_tag not in tag_name_to_id or target_tag not in tag_name_to_id:
return False
source_tag_id = tag_name_to_id[source_tag]
target_tag_id = tag_name_to_id[target_tag]
# This would need to be implemented in the database module
# For now, return False
return False
def get_photos_by_tags(self, tags: List[str], match_all: bool = False) -> List[Tuple]:
"""Get photos that have any (or all) of the specified tags"""
if not tags:
return []
tag_id_to_name, tag_name_to_id = self.db.load_tag_mappings()
tag_ids = []
for tag_name in tags:
# Convert to lowercase for case-insensitive lookup
normalized_tag_name = tag_name.lower().strip()
if normalized_tag_name in tag_name_to_id:
tag_ids.append(tag_name_to_id[normalized_tag_name])
if not tag_ids:
return []
# This would need to be implemented in the database module
# For now, return empty list
return []
def get_common_tags(self, photo_ids: List[int]) -> List[str]:
"""Get tags that are common to all specified photos"""
if not photo_ids:
return []
# Get tags for each photo
all_photo_tags = []
for photo_id in photo_ids:
tags = self.get_photo_tags(photo_id)
all_photo_tags.append(set(tags))
if not all_photo_tags:
return []
# Find intersection of all tag sets
common_tags = set.intersection(*all_photo_tags)
return list(common_tags)
def get_suggested_tags(self, photo_id: int, limit: int = 5) -> List[str]:
"""Get suggested tags based on similar photos"""
# This is a placeholder for tag suggestion logic
# Could be implemented based on:
# - Tags from photos in the same folder
# - Tags from photos taken on the same date
# - Most commonly used tags
# - Machine learning based suggestions
return []
def validate_tag_name(self, tag_name: str) -> Tuple[bool, str]:
"""Validate a tag name and return (is_valid, error_message)"""
if not tag_name or not tag_name.strip():
return False, "Tag name cannot be empty"
tag_name = tag_name.strip()
if len(tag_name) > 50:
return False, "Tag name is too long (max 50 characters)"
if ',' in tag_name:
return False, "Tag name cannot contain commas"
if tag_name.lower() in ['all', 'none', 'untagged']:
return False, "Tag name is reserved"
return True, ""