#!/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, ""