This commit introduces a comprehensive set of modules for the PunimTag application, including configuration management, database operations, face processing, photo management, and tag management. Each module is designed to encapsulate specific functionalities, enhancing maintainability and scalability. The GUI components are also integrated, allowing for a cohesive user experience. This foundational work sets the stage for future enhancements and features, ensuring a robust framework for photo tagging and face recognition tasks.
265 lines
9.2 KiB
Python
265 lines
9.2 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:
|
|
if tag_name in tag_name_to_id:
|
|
tag_ids.append(tag_name_to_id[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, ""
|