From d80825d3a1f83413e2abb23c839dfd88efcb4a02 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Wed, 2 Jul 2025 12:48:12 -0300 Subject: [PATCH 1/9] Started adding the reference system to add images by adding a hash to each photo and also the possibility to add a reference by pressing i whilst the sidebar is on focus --- src/pilgrim/ui/screens/edit_entry_screen.py | 368 ++++++++++++++---- .../ui/screens/modals/add_photo_modal.py | 23 +- .../ui/screens/modals/edit_photo_modal.py | 14 + 3 files changed, 327 insertions(+), 78 deletions(-) diff --git a/src/pilgrim/ui/screens/edit_entry_screen.py b/src/pilgrim/ui/screens/edit_entry_screen.py index 1408ccd..634078f 100644 --- a/src/pilgrim/ui/screens/edit_entry_screen.py +++ b/src/pilgrim/ui/screens/edit_entry_screen.py @@ -2,6 +2,9 @@ from typing import Optional, List import asyncio from datetime import datetime from pathlib import Path +import hashlib +import re +import time from textual.app import ComposeResult from textual.screen import Screen @@ -23,13 +26,15 @@ class EditEntryScreen(Screen): TITLE = "Pilgrim - Edit" BINDINGS = [ - Binding("ctrl+s", "save", "Save"), - Binding("ctrl+n", "next_entry", "Next/New Entry"), - Binding("ctrl+b", "prev_entry", "Previous Entry"), - Binding("ctrl+r", "rename_entry", "Rename Entry"), - Binding("escape", "back_to_list", "Back to List"), - Binding("f8", "toggle_sidebar", "Toggle Sidebar"), - Binding("f9", "toggle_focus", "Focus Sidebar/Editor"), + ("ctrl+q", "quit", "Quit"), + ("ctrl+s", "save", "Save"), + ("ctrl+n", "new_entry", "New Entry"), + ("ctrl+shift+n", "next_entry", "Next Entry"), + ("ctrl+shift+p", "prev_entry", "Previous Entry"), + ("ctrl+r", "rename_entry", "Rename Entry"), + ("f8", "toggle_sidebar", "Toggle Photos"), + ("f9", "toggle_focus", "Toggle Focus"), + ("escape", "back_to_list", "Back to List"), ] def __init__(self, diary_id: int = 1): @@ -50,6 +55,11 @@ class EditEntryScreen(Screen): self.sidebar_visible = False self.sidebar_focused = False self._sidebar_opened_once = False + self._active_tooltip = None + self._last_photo_suggestion_notification = None + self._last_photo_suggestion_type = None + self._active_notification = None + self._notification_timer = None # Main header self.header = Header(name="Pilgrim v6", classes="EditEntryScreen-header") @@ -108,6 +118,153 @@ class EditEntryScreen(Screen): """Forces footer refresh to show updated bindings""" self.refresh() + def _generate_photo_hash(self, photo: Photo) -> str: + """Generate a short, unique hash for a photo""" + unique_string = f"{photo.name}_{photo.id}_{photo.addition_date}" + hash_object = hashlib.md5(unique_string.encode()) + return hash_object.hexdigest()[:8] + + def _fuzzy_search(self, query: str, photos: List[Photo]) -> List[Photo]: + """Fuzzy search for photos by name or hash""" + if not query: + return [] + + query = query.lower() + results = [] + + for photo in photos: + photo_hash = self._generate_photo_hash(photo) + photo_name = photo.name.lower() + + # Check if query is in name (substring match) + if query in photo_name: + results.append((photo, 1, f"Name match: {query} in {photo.name}")) + continue + + # Check if query is in hash (substring match) + if query in photo_hash: + results.append((photo, 2, f"Hash match: {query} in {photo_hash}")) + continue + + # Fuzzy match for name (check if all characters are present in order) + if self._fuzzy_match(query, photo_name): + results.append((photo, 3, f"Fuzzy name match: {query} in {photo.name}")) + continue + + # Fuzzy match for hash + if self._fuzzy_match(query, photo_hash): + results.append((photo, 4, f"Fuzzy hash match: {query} in {photo_hash}")) + continue + + # Sort by priority (lower number = higher priority) + results.sort(key=lambda x: x[1]) + return [photo for photo, _, _ in results] + + def _fuzzy_match(self, query: str, text: str) -> bool: + """Check if query characters appear in text in order (fuzzy match)""" + if not query: + return True + + query_idx = 0 + for char in text: + if query_idx < len(query) and char == query[query_idx]: + query_idx += 1 + if query_idx == len(query): + return True + return False + + def _show_photo_tooltip(self, hash_value: str, cursor_position: tuple = None): + """Show tooltip with photo info when typing hash""" + # Temporarily disabled - using notifications instead + pass + + def _hide_tooltip(self): + """Hide the current tooltip""" + # Temporarily disabled + pass + + def _check_hash_tooltips(self, text: str): + """Check for hash patterns and show tooltips""" + # Temporarily disabled - using notifications instead + pass + + def _get_cursor_position(self) -> tuple: + """Get current cursor position for tooltip placement""" + try: + # Get cursor position from text area + cursor_location = self.text_entry.cursor_location + if cursor_location: + # Get the text area region + text_region = self.text_entry.region + if text_region: + # Calculate position relative to text area + # Position tooltip below the current line, not over it + x = text_region.x + min(cursor_location[0], text_region.width - 40) # Keep within bounds + y = text_region.y + cursor_location[1] + 2 # 2 lines below cursor + return (x, y) + except: + pass + return None + + def _update_photo_notification(self, message: str, severity: str = "info", timeout: int = 5): + """Update existing notification or create new one""" + # Cancel previous timer if exists + if self._notification_timer: + self._notification_timer.stop() + self._notification_timer = None + + # Update existing notification or create new one + if self._active_notification: + # Try to update existing notification + try: + self._active_notification.update(message) + print(f"DEBUG: Updated existing notification: {message}") + except: + # If update fails, create new notification + self._active_notification = self.notify(message, severity=severity, timeout=timeout) + print(f"DEBUG: Created new notification: {message}") + else: + # Create new notification + self._active_notification = self.notify(message, severity=severity, timeout=timeout) + print(f"DEBUG: Created new notification: {message}") + + # Set timer to clear notification after inactivity + self._notification_timer = self.set_timer(timeout, self._clear_photo_notification) + + def _show_photo_suggestion(self, message: str, timeout: int = 5): + # Temporarily disabled + pass + + def _hide_photo_suggestion(self): + # Temporarily disabled + pass + + def _clear_photo_notification(self): + """Clear the active photo notification""" + self._active_notification = None + self._notification_timer = None + print("DEBUG: Cleared photo notification") + + def _resolve_photo_references(self, text: str) -> str: + """Resolve photo references in text to actual photo information""" + def replace_photo_ref(match): + name_part = match.group(1) if match.group(1) else "" + hash_part = match.group(2) + + photos = self._load_photos_for_diary(self.diary_id) + + # Find photo by hash (most reliable) + matching_photos = [p for p in photos if self._generate_photo_hash(p) == hash_part] + + if matching_photos: + photo = matching_photos[0] + return f"\n[📷 {photo.name}]({photo.filepath})\n" + (f"*{photo.caption}*\n" if photo.caption else "") + else: + return f"\n[❌ Photo not found: hash={hash_part}]\n" + + # Match both formats: [[photo:name:hash]] and [[photo::hash]] + return re.sub(r'\[\[photo:([^:]*):([a-f0-9]{8})\]\]', replace_photo_ref, text) + def compose(self) -> ComposeResult: print("DEBUG: EditEntryScreen COMPOSE", getattr(self, 'sidebar_visible', None)) yield self.header @@ -130,6 +287,7 @@ class EditEntryScreen(Screen): # Initialize footer with editor context self._update_footer_context() + # self.app.mount(self._photo_suggestion_widget) # Temporarily disabled def update_diary_info(self): """Updates diary information""" @@ -249,37 +407,48 @@ class EditEntryScreen(Screen): def _update_sidebar_content(self): """Updates the sidebar content with photos for the current diary""" - photos = self._load_photos_for_diary(self.diary_id) + try: + photos = self._load_photos_for_diary(self.diary_id) - # Clear existing options safely - self.photo_list.clear_options() + # Clear existing options safely + self.photo_list.clear_options() - # Add 'Ingest Photo' option at the top - self.photo_list.add_option("➕ Ingest Photo") + # Add 'Ingest Photo' option at the top + self.photo_list.add_option("➕ Ingest Photo") - if not photos: - self.photo_info.update("No photos found for this diary") - self.help_text.update("📸 No photos available\n\nUse Photo Manager to add photos") - return + if not photos: + self.photo_info.update("No photos found for this diary") + self.help_text.update("📸 No photos available\n\nUse Photo Manager to add photos") + return - # Add photos to the list - for photo in photos: - self.photo_list.add_option(f"📷 {photo.name}") + # Add photos to the list with hash + for photo in photos: + # Show name and hash in the list + photo_hash = self._generate_photo_hash(photo) + self.photo_list.add_option(f"📷 {photo.name} \\[{photo_hash}\\]") - self.photo_info.update(f"📸 {len(photos)} photos in diary") - - # English, visually distinct help text - help_text = ( - "[b]⌨️ Sidebar Shortcuts[/b]\n" - "[b][green]i[/green][/b]: Insert photo into entry\n" - "[b][green]n[/green][/b]: Add new photo\n" - "[b][green]d[/green][/b]: Delete selected photo\n" - "[b][green]e[/green][/b]: Edit selected photo\n" - "[b][yellow]Tab[/yellow][/b]: Back to editor\n" - "[b][yellow]F8[/yellow][/b]: Show/hide sidebar\n" - "[b][yellow]F9[/yellow][/b]: Switch focus (if needed)" - ) - self.help_text.update(help_text) + self.photo_info.update(f"📸 {len(photos)} photos in diary") + + # Updated help text with hash information + help_text = ( + "[b]⌨️ Sidebar Shortcuts[/b]\n" + "[b][green]i[/green][/b]: Insert photo into entry\n" + "[b][green]n[/green][/b]: Add new photo\n" + "[b][green]d[/green][/b]: Delete selected photo\n" + "[b][green]e[/green][/b]: Edit selected photo\n" + "[b][yellow]Tab[/yellow][/b]: Back to editor\n" + "[b][yellow]F8[/yellow][/b]: Show/hide sidebar\n" + "[b][yellow]F9[/yellow][/b]: Switch focus (if needed)\n\n" + "[b]📝 Photo References[/b]\n" + "Use: \\[\\[photo:name:hash\\]\\]\n" + "Or: \\[\\[photo::hash\\]\\]" + ) + self.help_text.update(help_text) + except Exception as e: + self.notify(f"Error updating sidebar: {str(e)}", severity="error") + # Set fallback content + self.photo_info.update("Error loading photos") + self.help_text.update("Error loading sidebar content") def _load_photos_for_diary(self, diary_id: int) -> List[Photo]: """Loads all photos for the specific diary""" @@ -297,40 +466,49 @@ class EditEntryScreen(Screen): def action_toggle_sidebar(self): """Toggles the sidebar visibility""" - print("DEBUG: TOGGLE SIDEBAR", self.sidebar_visible) - self.sidebar_visible = not self.sidebar_visible - - if self.sidebar_visible: - self.sidebar.display = True - self._update_sidebar_content() - # Automatically focus the sidebar when opening - self.sidebar_focused = True - self.photo_list.focus() - # Notification when opening the sidebar for the first time - if not self._sidebar_opened_once: - self.notify( - "Sidebar opened and focused! Use the shortcuts shown in the help panel.", - severity="info" - ) - self._sidebar_opened_once = True - else: + try: + print("DEBUG: TOGGLE SIDEBAR", self.sidebar_visible) + self.sidebar_visible = not self.sidebar_visible + + if self.sidebar_visible: + self.sidebar.display = True + self._update_sidebar_content() + # Automatically focus the sidebar when opening + self.sidebar_focused = True + self.photo_list.focus() + # Notification when opening the sidebar for the first time + if not self._sidebar_opened_once: + self.notify( + "Sidebar opened and focused! Use the shortcuts shown in the help panel.", + severity="info" + ) + self._sidebar_opened_once = True + else: + self.sidebar.display = False + self.sidebar_focused = False # Reset focus when hiding + self.text_entry.focus() # Return focus to editor + + # Update footer after context change + self._update_footer_context() + self.refresh(layout=True) + except Exception as e: + self.notify(f"Error toggling sidebar: {str(e)}", severity="error") + # Reset state on error + self.sidebar_visible = False + self.sidebar_focused = False self.sidebar.display = False - self.sidebar_focused = False # Reset focus when hiding - self.text_entry.focus() # Return focus to editor - - # Update footer after context change - self._update_footer_context() - self.refresh(layout=True) def action_toggle_focus(self): """Toggles focus between editor and sidebar""" - print("DEBUG: TOGGLE FOCUS", self.sidebar_visible, self.sidebar_focused) + print("DEBUG: TOGGLE FOCUS called", self.sidebar_visible, self.sidebar_focused) if not self.sidebar_visible: # If sidebar is not visible, show it and focus it + print("DEBUG: Sidebar not visible, opening it") self.action_toggle_sidebar() return self.sidebar_focused = not self.sidebar_focused + print("DEBUG: Sidebar focused changed to", self.sidebar_focused) if self.sidebar_focused: self.photo_list.focus() else: @@ -359,19 +537,36 @@ class EditEntryScreen(Screen): return selected_photo = photos[photo_index] + photo_hash = self._generate_photo_hash(selected_photo) - # Insert photo reference into text - photo_ref = f"\n[📷 {selected_photo.name}]({selected_photo.filepath})\n" - if selected_photo.caption: - photo_ref += f"*{selected_photo.caption}*\n" + # Insert photo reference using hash format without escaping + # Using raw string to avoid markup conflicts with [[ + photo_ref = f"[[photo::{photo_hash}]]" - # Insert at cursor position or at end - current_text = self.text_entry.text - cursor_position = len(current_text) # Insert at end for now - new_text = current_text + photo_ref - self.text_entry.text = new_text + # Insert at cursor position + self.text_entry.insert(photo_ref) - self.notify(f"Inserted photo: {selected_photo.name}") + # Switch focus back to editor + self.sidebar_focused = False + self.text_entry.focus() + + # Update footer context + self._update_footer_context() + + # Show selected photo info + photo_details = f"📷 {selected_photo.name}\n" + photo_details += f"🔗 {photo_hash}\n" + photo_details += f"📅 {selected_photo.addition_date}\n" + photo_details += f"💬 {selected_photo.caption or 'No caption'}\n" + photo_details += f"📁 {selected_photo.filepath}\n\n" + photo_details += f"[b]Reference formats:[/b]\n" + photo_details += f"\\[\\[photo:{selected_photo.name}:{photo_hash}\\]\\]\n" + photo_details += f"\\[\\[photo::{photo_hash}\\]\\]" + + self.photo_info.update(photo_details) + + # Show notification without escaping brackets + self.notify(f"Inserted photo: {selected_photo.name} \\[{photo_hash}\\]", severity="information") def action_ingest_new_photo(self): """Ingest a new photo using modal""" @@ -564,30 +759,45 @@ class EditEntryScreen(Screen): """Handles photo selection in the sidebar""" if not self.sidebar_visible: return - # If 'Ingest Photo' is selected (always index 0) - if event.option_index == 0: - self.action_ingest_new_photo() - return + photos = self._load_photos_for_diary(self.diary_id) + if not photos or event.option_index <= 0: # Skip 'Ingest Photo' option + return + # Adjust index because of 'Ingest Photo' at the top photo_index = event.option_index - 1 - if not photos or photo_index >= len(photos): + if photo_index >= len(photos): return + selected_photo = photos[photo_index] - self.notify(f"Selected photo: {selected_photo.name}") - # Update photo info with details + photo_hash = self._generate_photo_hash(selected_photo) + self.notify(f"Selected photo: {selected_photo.name} \\[{photo_hash}\\]") + + # Update photo info with details including hash photo_details = f"📷 {selected_photo.name}\n" + photo_details += f"🔗 {photo_hash}\n" photo_details += f"📅 {selected_photo.addition_date}\n" if selected_photo.caption: photo_details += f"💬 {selected_photo.caption}\n" - photo_details += f"📁 {selected_photo.filepath}" + photo_details += f"📁 {selected_photo.filepath}\n\n" + photo_details += f"[b]Reference formats:[/b]\n" + photo_details += f"\\[\\[photo:{selected_photo.name}:{photo_hash}\\]\\]\n" + photo_details += f"\\[\\[photo::{photo_hash}\\]\\]" + self.photo_info.update(photo_details) def on_text_area_changed(self, event) -> None: - """Detects text changes to mark as unsaved""" + """Detects text changes and shows photo tooltips""" if (hasattr(self, 'text_entry') and not self.text_entry.read_only and not getattr(self, '_updating_display', False) and hasattr(self, '_original_content')): current_content = self.text_entry.text + + # Check for hash patterns and show tooltips + self._check_hash_tooltips(current_content) + + # Check for photo reference pattern + # self._check_photo_reference(current_content) # Temporarily disabled + if current_content != self._original_content: if not self.has_unsaved_changes: self.has_unsaved_changes = True @@ -800,18 +1010,24 @@ class EditEntryScreen(Screen): self.notify(f"Error updating entry: {str(e)}") def on_key(self, event): + print("DEBUG: on_key called with", event.key, "sidebar_focused:", self.sidebar_focused, "sidebar_visible:", self.sidebar_visible) # Sidebar contextual shortcuts if self.sidebar_focused and self.sidebar_visible: + print("DEBUG: Processing sidebar shortcut for key:", event.key) if event.key == "i": + print("DEBUG: Calling action_insert_photo") self.action_insert_photo() event.stop() elif event.key == "n": + print("DEBUG: Calling action_ingest_new_photo") self.action_ingest_new_photo() event.stop() elif event.key == "d": + print("DEBUG: Calling action_delete_photo") self.action_delete_photo() event.stop() elif event.key == "e": + print("DEBUG: Calling action_edit_photo") self.action_edit_photo() event.stop() # Shift+Tab: remove indent diff --git a/src/pilgrim/ui/screens/modals/add_photo_modal.py b/src/pilgrim/ui/screens/modals/add_photo_modal.py index e0ac578..89181b2 100644 --- a/src/pilgrim/ui/screens/modals/add_photo_modal.py +++ b/src/pilgrim/ui/screens/modals/add_photo_modal.py @@ -5,6 +5,7 @@ from textual.screen import Screen from textual.widgets import Static, Input, Button from textual.containers import Horizontal, Container from .file_picker_modal import FilePickerModal +import hashlib class AddPhotoModal(Screen): """Modal for adding a new photo""" @@ -12,6 +13,14 @@ class AddPhotoModal(Screen): super().__init__() self.diary_id = diary_id self.result = None + self.created_photo = None + + def _generate_photo_hash(self, photo_data: dict) -> str: + """Generate a short, unique hash for a photo""" + # Use temporary data for hash generation + unique_string = f"{photo_data['name']}_{photo_data.get('photo_id', 0)}_new" + hash_object = hashlib.md5(unique_string.encode()) + return hash_object.hexdigest()[:8] def compose(self) -> ComposeResult: yield Container( @@ -72,13 +81,23 @@ class AddPhotoModal(Screen): ) if new_photo: - self.notify(f"Photo '{new_photo.name}' added successfully!") + self.created_photo = new_photo + # Generate hash for the new photo + photo_hash = self._generate_photo_hash({ + "name": new_photo.name, + "photo_id": new_photo.id + }) + + self.notify(f"Photo '{new_photo.name}' added successfully!\nHash: {photo_hash}\nReference: \\[\\[photo:{new_photo.name}:{photo_hash}\\]\\]", + severity="information", timeout=5) + # Return the created photo data to the calling screen self.result = { "filepath": photo_data["filepath"], "name": photo_data["name"], "caption": photo_data["caption"], - "photo_id": new_photo.id + "photo_id": new_photo.id, + "hash": photo_hash } self.dismiss(self.result) else: diff --git a/src/pilgrim/ui/screens/modals/edit_photo_modal.py b/src/pilgrim/ui/screens/modals/edit_photo_modal.py index 5af3011..9a44d1d 100644 --- a/src/pilgrim/ui/screens/modals/edit_photo_modal.py +++ b/src/pilgrim/ui/screens/modals/edit_photo_modal.py @@ -3,6 +3,7 @@ from textual.screen import Screen from textual.widgets import Static, Input, Button from textual.containers import Container, Horizontal from pilgrim.models.photo import Photo +import hashlib class EditPhotoModal(Screen): """Modal for editing an existing photo (name and caption only)""" @@ -11,7 +12,16 @@ class EditPhotoModal(Screen): self.photo = photo self.result = None + def _generate_photo_hash(self, photo: Photo) -> str: + """Generate a short, unique hash for a photo""" + unique_string = f"{photo.name}_{photo.id}_{photo.addition_date}" + hash_object = hashlib.md5(unique_string.encode()) + return hash_object.hexdigest()[:8] + def compose(self) -> ComposeResult: + # Generate hash for this photo + photo_hash = self._generate_photo_hash(self.photo) + yield Container( Static("✏️ Edit Photo", classes="EditPhotoModal-Title"), Static("File path (read-only):", classes="EditPhotoModal-Label"), @@ -35,6 +45,10 @@ class EditPhotoModal(Screen): id="caption-input", classes="EditPhotoModal-Input" ), + Static(f"🔗 Photo Hash: {photo_hash}", classes="EditPhotoModal-Hash"), + Static("Reference formats:", classes="EditPhotoModal-Label"), + Static(f"\\[\\[photo:{self.photo.name}:{photo_hash}\\]\\]", classes="EditPhotoModal-Reference"), + Static(f"\\[\\[photo::{photo_hash}\\]\\]", classes="EditPhotoModal-Reference"), Horizontal( Button("Save Changes", id="save-button", classes="EditPhotoModal-Button"), Button("Cancel", id="cancel-button", classes="EditPhotoModal-Button"), From d47259fc68cd17472f73d3300846427832b4fd51 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 3 Jul 2025 01:33:31 -0300 Subject: [PATCH 2/9] Removed Stale methods on edit_entry_screen.py --- src/pilgrim/ui/screens/edit_entry_screen.py | 73 +-------------------- 1 file changed, 1 insertion(+), 72 deletions(-) diff --git a/src/pilgrim/ui/screens/edit_entry_screen.py b/src/pilgrim/ui/screens/edit_entry_screen.py index 634078f..481dffd 100644 --- a/src/pilgrim/ui/screens/edit_entry_screen.py +++ b/src/pilgrim/ui/screens/edit_entry_screen.py @@ -173,20 +173,8 @@ class EditEntryScreen(Screen): return True return False - def _show_photo_tooltip(self, hash_value: str, cursor_position: tuple = None): - """Show tooltip with photo info when typing hash""" - # Temporarily disabled - using notifications instead - pass - def _hide_tooltip(self): - """Hide the current tooltip""" - # Temporarily disabled - pass - def _check_hash_tooltips(self, text: str): - """Check for hash patterns and show tooltips""" - # Temporarily disabled - using notifications instead - pass def _get_cursor_position(self) -> tuple: """Get current cursor position for tooltip placement""" @@ -206,64 +194,6 @@ class EditEntryScreen(Screen): pass return None - def _update_photo_notification(self, message: str, severity: str = "info", timeout: int = 5): - """Update existing notification or create new one""" - # Cancel previous timer if exists - if self._notification_timer: - self._notification_timer.stop() - self._notification_timer = None - - # Update existing notification or create new one - if self._active_notification: - # Try to update existing notification - try: - self._active_notification.update(message) - print(f"DEBUG: Updated existing notification: {message}") - except: - # If update fails, create new notification - self._active_notification = self.notify(message, severity=severity, timeout=timeout) - print(f"DEBUG: Created new notification: {message}") - else: - # Create new notification - self._active_notification = self.notify(message, severity=severity, timeout=timeout) - print(f"DEBUG: Created new notification: {message}") - - # Set timer to clear notification after inactivity - self._notification_timer = self.set_timer(timeout, self._clear_photo_notification) - - def _show_photo_suggestion(self, message: str, timeout: int = 5): - # Temporarily disabled - pass - - def _hide_photo_suggestion(self): - # Temporarily disabled - pass - - def _clear_photo_notification(self): - """Clear the active photo notification""" - self._active_notification = None - self._notification_timer = None - print("DEBUG: Cleared photo notification") - - def _resolve_photo_references(self, text: str) -> str: - """Resolve photo references in text to actual photo information""" - def replace_photo_ref(match): - name_part = match.group(1) if match.group(1) else "" - hash_part = match.group(2) - - photos = self._load_photos_for_diary(self.diary_id) - - # Find photo by hash (most reliable) - matching_photos = [p for p in photos if self._generate_photo_hash(p) == hash_part] - - if matching_photos: - photo = matching_photos[0] - return f"\n[📷 {photo.name}]({photo.filepath})\n" + (f"*{photo.caption}*\n" if photo.caption else "") - else: - return f"\n[❌ Photo not found: hash={hash_part}]\n" - - # Match both formats: [[photo:name:hash]] and [[photo::hash]] - return re.sub(r'\[\[photo:([^:]*):([a-f0-9]{8})\]\]', replace_photo_ref, text) def compose(self) -> ComposeResult: print("DEBUG: EditEntryScreen COMPOSE", getattr(self, 'sidebar_visible', None)) @@ -792,8 +722,7 @@ class EditEntryScreen(Screen): not getattr(self, '_updating_display', False) and hasattr(self, '_original_content')): current_content = self.text_entry.text - # Check for hash patterns and show tooltips - self._check_hash_tooltips(current_content) + # Check for photo reference pattern # self._check_photo_reference(current_content) # Temporarily disabled From 8cc42e390ad7de21ff66ad014f6600344b411e88 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 3 Jul 2025 21:08:24 -0300 Subject: [PATCH 3/9] Added Hash capability to the model photo.py and also updated the service to create hashes and update hashes --- src/pilgrim/models/photo.py | 4 +++- src/pilgrim/service/photo_service.py | 16 ++++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/pilgrim/models/photo.py b/src/pilgrim/models/photo.py index a164474..02fdcc0 100644 --- a/src/pilgrim/models/photo.py +++ b/src/pilgrim/models/photo.py @@ -16,6 +16,7 @@ class Photo(Base): name = Column(String) addition_date = Column(DateTime, default=datetime.now) caption = Column(String) + photo_hash = Column(String,name='hash') entries = relationship( "Entry", secondary=photo_entry_association, @@ -24,7 +25,7 @@ class Photo(Base): fk_travel_diary_id = Column(Integer, ForeignKey("travel_diaries.id"),nullable=False) - def __init__(self, filepath, name, addition_date=None, caption=None, entries=None, fk_travel_diary_id=None, **kw: Any): + def __init__(self, filepath, name, photo_hash, addition_date=None, caption=None, entries=None, fk_travel_diary_id=None, **kw: Any): super().__init__(**kw) # Convert Path to string if needed if isinstance(filepath, Path): @@ -34,6 +35,7 @@ class Photo(Base): self.name = name self.addition_date = addition_date if addition_date is not None else datetime.now() self.caption = caption + self.photo_hash = photo_hash self.entries = entries if entries is not None else [] if fk_travel_diary_id is not None: self.fk_travel_diary_id = fk_travel_diary_id diff --git a/src/pilgrim/service/photo_service.py b/src/pilgrim/service/photo_service.py index df87902..ed1b2e3 100644 --- a/src/pilgrim/service/photo_service.py +++ b/src/pilgrim/service/photo_service.py @@ -1,6 +1,7 @@ from pathlib import Path from typing import List from datetime import datetime +import hashlib from pilgrim.models.photo import Photo @@ -9,6 +10,12 @@ from pilgrim.models.travel_diary import TravelDiary class PhotoService: def __init__(self, session): self.session = session + def _hash_file(self,filepath): + hash_func = hashlib.new('sha3_384') + with open(filepath, 'rb') as f: + while chunk := f.read(8192): + hash_func.update(chunk) + return hash_func.hexdigest() def create(self, filepath: Path, name: str, travel_diary_id: int, caption=None, addition_date=None) -> Photo | None: travel_diary = self.session.query(TravelDiary).filter(TravelDiary.id == travel_diary_id).first() @@ -27,7 +34,8 @@ class PhotoService: name=name, caption=caption, fk_travel_diary_id=travel_diary_id, - addition_date=addition_date + addition_date=addition_date, + photo_hash=self._hash_file(filepath) ) self.session.add(new_photo) self.session.commit() @@ -47,6 +55,7 @@ class PhotoService: original.name = photo_dst.name original.addition_date = photo_dst.addition_date original.caption = photo_dst.caption + original.photo_hash = original.photo_hash if photo_dst.entries and len(photo_dst.entries) > 0: if original.entries is None: original.entries = [] @@ -66,7 +75,10 @@ class PhotoService: addition_date=excluded.addition_date, caption=excluded.caption, fk_travel_diary_id=excluded.fk_travel_diary_id, - id=excluded.id + id=excluded.id, + photo_hash=excluded.photo_hash, + entries=excluded.entries, + ) self.session.delete(excluded) From 183e0bc8c79e689afa80bc78585fcb9b5960d359 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 3 Jul 2025 21:09:09 -0300 Subject: [PATCH 4/9] Started implementing the proper hash on the photo sidebar --- src/pilgrim/ui/screens/edit_entry_screen.py | 40 ++++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/src/pilgrim/ui/screens/edit_entry_screen.py b/src/pilgrim/ui/screens/edit_entry_screen.py index 481dffd..b735852 100644 --- a/src/pilgrim/ui/screens/edit_entry_screen.py +++ b/src/pilgrim/ui/screens/edit_entry_screen.py @@ -60,6 +60,7 @@ class EditEntryScreen(Screen): self._last_photo_suggestion_type = None self._active_notification = None self._notification_timer = None + self.references = [] # Main header self.header = Header(name="Pilgrim v6", classes="EditEntryScreen-header") @@ -453,7 +454,7 @@ class EditEntryScreen(Screen): self.notify("Use F8 to open the sidebar first.", severity="warning") return - # Get selected photo + # Get a selected photo if self.photo_list.highlighted is None: self.notify("No photo selected", severity="warning") return @@ -505,10 +506,15 @@ class EditEntryScreen(Screen): return # Open add photo modal - self.app.push_screen( - AddPhotoModal(diary_id=self.diary_id), - self.handle_add_photo_result - ) + try: + self.notify("Trying to push the modal screen...") + self.app.push_screen( + AddPhotoModal(diary_id=self.diary_id), + self.handle_add_photo_result + ) + except Exception as e: + self.notify(f"Error: {str(e)}", severity="error") + self.app.notify("Error: {str(e)}", severity="error") def handle_add_photo_result(self, result: dict | None) -> None: """Callback that processes the add photo modal result.""" @@ -853,6 +859,8 @@ class EditEntryScreen(Screen): def action_save(self) -> None: """Saves the current entry""" + self._get_all_references() + self._validate_references() if self.is_new_entry: content = self.text_entry.text.strip() if not content: @@ -937,7 +945,29 @@ class EditEntryScreen(Screen): except Exception as e: self.notify(f"Error updating entry: {str(e)}") + def _get_all_references(self): + text_content = self.text_entry.text + matches = re.findall("(\[\[photo::?(?:\w|\s)*\]\])", text_content) + for match in matches: + if re.match(r"\[\[photo::\w+\]\]", match): + if {'type': 'hash','value':match.replace("[[photo::", "").replace("]]", "").strip()} not in self.references: + self.references.append( + {'type': 'hash', 'value': match.replace("[[photo::", "").replace("]]", "").strip()}) + elif re.match(r"\[\[photo:\w+\]\]", match): + if {'type': 'name', 'value': match.replace("[[photo:", "").replace("]]", "").strip()} not in self.references: + self.references.append( + {'type': 'name', 'value': match.replace("[[photo:", "").replace("]]", "").strip()}) + else: + self.references.append({'type': 'unknown', 'value': match}) + self.notify(f"🔍 Referências encontradas: {str(self.references)}", markup=False) + + def _validate_references(self): + for reference in self.references: + if reference['type'] == 'hash': + self.notify("hash") + elif reference['type'] == 'name': + self.notify("name") def on_key(self, event): print("DEBUG: on_key called with", event.key, "sidebar_focused:", self.sidebar_focused, "sidebar_visible:", self.sidebar_visible) # Sidebar contextual shortcuts From 3754a68a80b04c5495d76089071cdea1bd962e92 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 3 Jul 2025 23:10:13 -0300 Subject: [PATCH 5/9] Changed to accept the proper hash created by the photo_service also started the function to validate the reference before getting all references on save --- src/pilgrim/ui/screens/edit_entry_screen.py | 73 +++++---------------- 1 file changed, 15 insertions(+), 58 deletions(-) diff --git a/src/pilgrim/ui/screens/edit_entry_screen.py b/src/pilgrim/ui/screens/edit_entry_screen.py index b735852..634b344 100644 --- a/src/pilgrim/ui/screens/edit_entry_screen.py +++ b/src/pilgrim/ui/screens/edit_entry_screen.py @@ -125,55 +125,6 @@ class EditEntryScreen(Screen): hash_object = hashlib.md5(unique_string.encode()) return hash_object.hexdigest()[:8] - def _fuzzy_search(self, query: str, photos: List[Photo]) -> List[Photo]: - """Fuzzy search for photos by name or hash""" - if not query: - return [] - - query = query.lower() - results = [] - - for photo in photos: - photo_hash = self._generate_photo_hash(photo) - photo_name = photo.name.lower() - - # Check if query is in name (substring match) - if query in photo_name: - results.append((photo, 1, f"Name match: {query} in {photo.name}")) - continue - - # Check if query is in hash (substring match) - if query in photo_hash: - results.append((photo, 2, f"Hash match: {query} in {photo_hash}")) - continue - - # Fuzzy match for name (check if all characters are present in order) - if self._fuzzy_match(query, photo_name): - results.append((photo, 3, f"Fuzzy name match: {query} in {photo.name}")) - continue - - # Fuzzy match for hash - if self._fuzzy_match(query, photo_hash): - results.append((photo, 4, f"Fuzzy hash match: {query} in {photo_hash}")) - continue - - # Sort by priority (lower number = higher priority) - results.sort(key=lambda x: x[1]) - return [photo for photo, _, _ in results] - - def _fuzzy_match(self, query: str, text: str) -> bool: - """Check if query characters appear in text in order (fuzzy match)""" - if not query: - return True - - query_idx = 0 - for char in text: - if query_idx < len(query) and char == query[query_idx]: - query_idx += 1 - if query_idx == len(query): - return True - return False - @@ -355,12 +306,12 @@ class EditEntryScreen(Screen): # Add photos to the list with hash for photo in photos: # Show name and hash in the list - photo_hash = self._generate_photo_hash(photo) - self.photo_list.add_option(f"📷 {photo.name} \\[{photo_hash}\\]") + photo_hash = str(photo.photo_hash)[:8] + self.photo_list.add_option(f"📷 {photo.name} \\[{photo_hash}\]") self.photo_info.update(f"📸 {len(photos)} photos in diary") - # Updated help text with hash information + # Updated help a text with hash information help_text = ( "[b]⌨️ Sidebar Shortcuts[/b]\n" "[b][green]i[/green][/b]: Insert photo into entry\n" @@ -468,13 +419,13 @@ class EditEntryScreen(Screen): return selected_photo = photos[photo_index] - photo_hash = self._generate_photo_hash(selected_photo) + photo_hash = selected_photo.photo_hash[:8] # Insert photo reference using hash format without escaping # Using raw string to avoid markup conflicts with [[ photo_ref = f"[[photo::{photo_hash}]]" - # Insert at cursor position + # Insert at the cursor position self.text_entry.insert(photo_ref) # Switch focus back to editor @@ -696,8 +647,13 @@ class EditEntryScreen(Screen): if not self.sidebar_visible: return + # Handle "Ingest Photo" option + if event.option_index == 0: # First option is "Ingest Photo" + self.action_ingest_new_photo() + return + photos = self._load_photos_for_diary(self.diary_id) - if not photos or event.option_index <= 0: # Skip 'Ingest Photo' option + if not photos: return # Adjust index because of 'Ingest Photo' at the top @@ -706,7 +662,7 @@ class EditEntryScreen(Screen): return selected_photo = photos[photo_index] - photo_hash = self._generate_photo_hash(selected_photo) + photo_hash = selected_photo.photo_hash[:8] self.notify(f"Selected photo: {selected_photo.name} \\[{photo_hash}\\]") # Update photo info with details including hash @@ -963,9 +919,10 @@ class EditEntryScreen(Screen): self.notify(f"🔍 Referências encontradas: {str(self.references)}", markup=False) def _validate_references(self): - for reference in self.references: + photo_service = self.app.service_manager.get_photo_service() + for reference in self.references: if reference['type'] == 'hash': - self.notify("hash") + pass elif reference['type'] == 'name': self.notify("name") def on_key(self, event): From 0ec480a8515079fbe44b30dba08e08cb53a6e868 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Fri, 4 Jul 2025 19:14:16 -0300 Subject: [PATCH 6/9] Removed some stale functions on edit_entry_screen.py changed and added some functions to work with photo references and added a photo cache to better database performance. --- src/pilgrim/ui/screens/edit_entry_screen.py | 77 +++++++++++++-------- 1 file changed, 47 insertions(+), 30 deletions(-) diff --git a/src/pilgrim/ui/screens/edit_entry_screen.py b/src/pilgrim/ui/screens/edit_entry_screen.py index 634b344..f4a8e68 100644 --- a/src/pilgrim/ui/screens/edit_entry_screen.py +++ b/src/pilgrim/ui/screens/edit_entry_screen.py @@ -61,6 +61,7 @@ class EditEntryScreen(Screen): self._active_notification = None self._notification_timer = None self.references = [] + self.cached_photos = [] # Main header self.header = Header(name="Pilgrim v6", classes="EditEntryScreen-header") @@ -116,20 +117,11 @@ class EditEntryScreen(Screen): self.footer = Footer(classes="EditEntryScreen-footer") def _update_footer_context(self): - """Forces footer refresh to show updated bindings""" + """Force footer refresh to show updated bindings""" self.refresh() - def _generate_photo_hash(self, photo: Photo) -> str: - """Generate a short, unique hash for a photo""" - unique_string = f"{photo.name}_{photo.id}_{photo.addition_date}" - hash_object = hashlib.md5(unique_string.encode()) - return hash_object.hexdigest()[:8] - - - - def _get_cursor_position(self) -> tuple: - """Get current cursor position for tooltip placement""" + """Get the current cursor position for tooltip placement""" try: # Get cursor position from text area cursor_location = self.text_entry.cursor_location @@ -290,26 +282,26 @@ class EditEntryScreen(Screen): def _update_sidebar_content(self): """Updates the sidebar content with photos for the current diary""" try: - photos = self._load_photos_for_diary(self.diary_id) + self._load_photos_for_diary(self.diary_id) # Clear existing options safely self.photo_list.clear_options() - # Add 'Ingest Photo' option at the top + # Add the 'Ingest Photo' option at the top self.photo_list.add_option("➕ Ingest Photo") - if not photos: + if not self.cached_photos: self.photo_info.update("No photos found for this diary") self.help_text.update("📸 No photos available\n\nUse Photo Manager to add photos") return # Add photos to the list with hash - for photo in photos: + for photo in self.cached_photos: # Show name and hash in the list photo_hash = str(photo.photo_hash)[:8] self.photo_list.add_option(f"📷 {photo.name} \\[{photo_hash}\]") - self.photo_info.update(f"📸 {len(photos)} photos in diary") + self.photo_info.update(f"📸 {len(self.cached_photos)} photos in diary") # Updated help a text with hash information help_text = ( @@ -332,19 +324,19 @@ class EditEntryScreen(Screen): self.photo_info.update("Error loading photos") self.help_text.update("Error loading sidebar content") - def _load_photos_for_diary(self, diary_id: int) -> List[Photo]: + def _load_photos_for_diary(self, diary_id: int): """Loads all photos for the specific diary""" try: service_manager = self.app.service_manager photo_service = service_manager.get_photo_service() all_photos = photo_service.read_all() - photos = [photo for photo in all_photos if photo.fk_travel_diary_id == diary_id] - photos.sort(key=lambda x: x.id) - return photos + self.cached_photos = [photo for photo in all_photos if photo.fk_travel_diary_id == diary_id] + self.cached_photos.sort(key=lambda x: x.id) + except Exception as e: self.notify(f"Error loading photos: {str(e)}") - return [] + def action_toggle_sidebar(self): """Toggles the sidebar visibility""" @@ -401,6 +393,7 @@ class EditEntryScreen(Screen): def action_insert_photo(self): """Insert selected photo into text""" + if not self.sidebar_focused or not self.sidebar_visible: self.notify("Use F8 to open the sidebar first.", severity="warning") return @@ -413,12 +406,12 @@ class EditEntryScreen(Screen): # Adjust index because of 'Ingest Photo' at the top photo_index = self.photo_list.highlighted - 1 - photos = self._load_photos_for_diary(self.diary_id) - if photo_index < 0 or photo_index >= len(photos): + self._load_photos_for_diary(self.diary_id) + if photo_index < 0 or photo_index >= len(self.cached_photos): self.notify("No photo selected", severity="warning") return - selected_photo = photos[photo_index] + selected_photo = self.cached_photos[photo_index] photo_hash = selected_photo.photo_hash[:8] # Insert photo reference using hash format without escaping @@ -534,7 +527,7 @@ class EditEntryScreen(Screen): def handle_delete_photo_result(self, result: bool) -> None: """Callback that processes the delete photo modal result.""" if result: - # Get the selected photo with adjusted index + # Get the selected photo with an adjusted index photos = self._load_photos_for_diary(self.diary_id) photo_index = self.photo_list.highlighted - 1 # Adjust for 'Ingest Photo' at top @@ -642,6 +635,31 @@ class EditEntryScreen(Screen): except Exception as e: self.notify(f"Error updating photo: {str(e)}") + def _get_linked_photos_from_text(self) -> Optional[List[Photo]]: + """ + Valida as referências de hash curto no texto contra o cache em memória. + - Retorna uma lista de objetos Photo se todas as referências forem válidas. + - Retorna None se UMA ÚNICA referência for inválida ou ambígua. + """ + text = self.text_entry.text + pattern = r"\[\[photo::(\w{8})\]\]" + short_hashes_in_text = set(re.findall(pattern, text)) + + if not short_hashes_in_text: + return [] # Nenhuma referência, operação válida. + + linked_photos: List[Photo] = [] + for short_hash in short_hashes_in_text: + found_photos = [p for p in self.cached_photos if p.photo_hash.startswith(short_hash)] + + if len(found_photos) == 1: + linked_photos.append(found_photos[0]) + else: + self.notify(f"❌ Erro: Referência '[{short_hash}]' é inválida ou ambígua!", severity="error", timeout=10) + return None # Aborta a operação + + return linked_photos + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: """Handles photo selection in the sidebar""" if not self.sidebar_visible: @@ -686,7 +704,7 @@ class EditEntryScreen(Screen): - # Check for photo reference pattern + # Check for a photo reference pattern # self._check_photo_reference(current_content) # Temporarily disabled if current_content != self._original_content: @@ -700,7 +718,7 @@ class EditEntryScreen(Screen): def on_focus(self, event) -> None: """Captures focus changes to update footer""" - # Check if focus changed to/from sidebar + # Check if the focus changed to/from sidebar if hasattr(event.widget, 'id'): if event.widget.id == "photo_list": self.sidebar_focused = True @@ -922,9 +940,8 @@ class EditEntryScreen(Screen): photo_service = self.app.service_manager.get_photo_service() for reference in self.references: if reference['type'] == 'hash': - pass - elif reference['type'] == 'name': - self.notify("name") + pass + def on_key(self, event): print("DEBUG: on_key called with", event.key, "sidebar_focused:", self.sidebar_focused, "sidebar_visible:", self.sidebar_visible) # Sidebar contextual shortcuts From e492e2c2489e6c14fef972504707696c52ecabcc Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Sat, 5 Jul 2025 05:54:18 -0300 Subject: [PATCH 7/9] Finished The basic Reference System it now parses the references added by the sidebar or written but only saves if the reference is valid --- src/pilgrim/service/entry_service.py | 23 ++- src/pilgrim/ui/screens/edit_entry_screen.py | 210 +++++++++----------- 2 files changed, 109 insertions(+), 124 deletions(-) diff --git a/src/pilgrim/service/entry_service.py b/src/pilgrim/service/entry_service.py index 0ce0ad8..040f5a8 100644 --- a/src/pilgrim/service/entry_service.py +++ b/src/pilgrim/service/entry_service.py @@ -3,32 +3,39 @@ from typing import List from ..models.entry import Entry from ..models.travel_diary import TravelDiary +from ..models.photo import Photo # ✨ Importe o modelo Photo class EntryService: - def __init__(self,session): + def __init__(self, session): self.session = session - def create(self, travel_diary_id:int, title: str, text: str, date: datetime, ): + # ✨ Modifique a assinatura para aceitar a lista de fotos + def create(self, travel_diary_id: int, title: str, text: str, date: datetime, photos: List[Photo]): travel_diary = self.session.query(TravelDiary).filter(TravelDiary.id == travel_diary_id).first() if not travel_diary: return None - new_entry = Entry(title,text,date,travel_diary_id) + + new_entry = Entry(title, text, date, travel_diary_id,photos=photos) + + # ✨ Atribua a relação ANTES de adicionar e fazer o commit + new_entry.photos = photos + self.session.add(new_entry) self.session.commit() self.session.refresh(new_entry) return new_entry - def read_by_id(self,entry_id:int)->Entry: + def read_by_id(self, entry_id: int) -> Entry: entry = self.session.query(Entry).filter(Entry.id == entry_id).first() return entry - def read_all(self)-> List[Entry]: + def read_all(self) -> List[Entry]: entries = self.session.query(Entry).all() return entries - def update(self,entry_src:Entry,entry_dst:Entry) -> Entry | None: - original:Entry = self.read_by_id(entry_src.id) + def update(self, entry_src: Entry, entry_dst: Entry) -> Entry | None: + original: Entry = self.read_by_id(entry_src.id) if original: original.title = entry_dst.title original.text = entry_dst.text @@ -40,7 +47,7 @@ class EntryService: return original return None - def delete(self,entry_src:Entry)-> Entry | None: + def delete(self, entry_src: Entry) -> Entry | None: excluded = self.read_by_id(entry_src.id) if excluded is not None: self.session.delete(excluded) diff --git a/src/pilgrim/ui/screens/edit_entry_screen.py b/src/pilgrim/ui/screens/edit_entry_screen.py index f4a8e68..3897fcb 100644 --- a/src/pilgrim/ui/screens/edit_entry_screen.py +++ b/src/pilgrim/ui/screens/edit_entry_screen.py @@ -154,11 +154,11 @@ class EditEntryScreen(Screen): """Called when the screen is mounted""" self.sidebar.display = False self.sidebar_visible = False - + # First update diary info, then refresh entries self.update_diary_info() self.refresh_entries() - + # Initialize footer with editor context self._update_footer_context() # self.app.mount(self._photo_suggestion_widget) # Temporarily disabled @@ -168,7 +168,7 @@ class EditEntryScreen(Screen): try: service_manager = self.app.service_manager travel_diary_service = service_manager.get_travel_diary_service() - + diary = travel_diary_service.read_by_id(self.diary_id) if diary: self.diary_name = diary.name @@ -181,7 +181,7 @@ class EditEntryScreen(Screen): self.diary_name = f"Diary {self.diary_id}" self.diary_info.update(f"Diary: {self.diary_name}") self.notify(f"Error loading diary info: {str(e)}") - + self._ensure_diary_info_updated() def _ensure_diary_info_updated(self): @@ -211,7 +211,7 @@ class EditEntryScreen(Screen): except Exception as e: self.notify(f"Error loading entries: {str(e)}") - + self._ensure_diary_info_updated() def _update_status_indicator(self, text: str, css_class: str): @@ -302,7 +302,7 @@ class EditEntryScreen(Screen): self.photo_list.add_option(f"📷 {photo.name} \\[{photo_hash}\]") self.photo_info.update(f"📸 {len(self.cached_photos)} photos in diary") - + # Updated help a text with hash information help_text = ( "[b]⌨️ Sidebar Shortcuts[/b]\n" @@ -329,7 +329,7 @@ class EditEntryScreen(Screen): try: service_manager = self.app.service_manager photo_service = service_manager.get_photo_service() - + all_photos = photo_service.read_all() self.cached_photos = [photo for photo in all_photos if photo.fk_travel_diary_id == diary_id] self.cached_photos.sort(key=lambda x: x.id) @@ -343,7 +343,7 @@ class EditEntryScreen(Screen): try: print("DEBUG: TOGGLE SIDEBAR", self.sidebar_visible) self.sidebar_visible = not self.sidebar_visible - + if self.sidebar_visible: self.sidebar.display = True self._update_sidebar_content() @@ -361,7 +361,7 @@ class EditEntryScreen(Screen): self.sidebar.display = False self.sidebar_focused = False # Reset focus when hiding self.text_entry.focus() # Return focus to editor - + # Update footer after context change self._update_footer_context() self.refresh(layout=True) @@ -380,14 +380,14 @@ class EditEntryScreen(Screen): print("DEBUG: Sidebar not visible, opening it") self.action_toggle_sidebar() return - + self.sidebar_focused = not self.sidebar_focused print("DEBUG: Sidebar focused changed to", self.sidebar_focused) if self.sidebar_focused: self.photo_list.focus() else: self.text_entry.focus() - + # Update footer after focus change self._update_footer_context() @@ -397,37 +397,37 @@ class EditEntryScreen(Screen): if not self.sidebar_focused or not self.sidebar_visible: self.notify("Use F8 to open the sidebar first.", severity="warning") return - + # Get a selected photo if self.photo_list.highlighted is None: self.notify("No photo selected", severity="warning") return - + # Adjust index because of 'Ingest Photo' at the top photo_index = self.photo_list.highlighted - 1 - + self._load_photos_for_diary(self.diary_id) if photo_index < 0 or photo_index >= len(self.cached_photos): self.notify("No photo selected", severity="warning") return - + selected_photo = self.cached_photos[photo_index] photo_hash = selected_photo.photo_hash[:8] - + # Insert photo reference using hash format without escaping # Using raw string to avoid markup conflicts with [[ photo_ref = f"[[photo::{photo_hash}]]" - + # Insert at the cursor position self.text_entry.insert(photo_ref) - + # Switch focus back to editor self.sidebar_focused = False self.text_entry.focus() - + # Update footer context self._update_footer_context() - + # Show selected photo info photo_details = f"📷 {selected_photo.name}\n" photo_details += f"🔗 {photo_hash}\n" @@ -437,9 +437,9 @@ class EditEntryScreen(Screen): photo_details += f"[b]Reference formats:[/b]\n" photo_details += f"\\[\\[photo:{selected_photo.name}:{photo_hash}\\]\\]\n" photo_details += f"\\[\\[photo::{photo_hash}\\]\\]" - + self.photo_info.update(photo_details) - + # Show notification without escaping brackets self.notify(f"Inserted photo: {selected_photo.name} \\[{photo_hash}\\]", severity="information") @@ -448,7 +448,7 @@ class EditEntryScreen(Screen): if not self.sidebar_focused or not self.sidebar_visible: self.notify("Use F8 to open the sidebar first.", severity="warning") return - + # Open add photo modal try: self.notify("Trying to push the modal screen...") @@ -478,7 +478,7 @@ class EditEntryScreen(Screen): photo_service = service_manager.get_photo_service() current_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - + new_photo = photo_service.create( filepath=Path(photo_data["filepath"]), name=photo_data["name"], @@ -503,21 +503,21 @@ class EditEntryScreen(Screen): if not self.sidebar_focused or not self.sidebar_visible: self.notify("Use F8 to open the sidebar first.", severity="warning") return - + if self.photo_list.highlighted is None: self.notify("No photo selected", severity="warning") return - + # Adjust index because of 'Ingest Photo' at the top photo_index = self.photo_list.highlighted - 1 - + photos = self._load_photos_for_diary(self.diary_id) if photo_index < 0 or photo_index >= len(photos): self.notify("No photo selected", severity="warning") return - + selected_photo = photos[photo_index] - + # Open confirm delete modal self.app.push_screen( ConfirmDeleteModal(photo=selected_photo), @@ -530,13 +530,13 @@ class EditEntryScreen(Screen): # Get the selected photo with an adjusted index photos = self._load_photos_for_diary(self.diary_id) photo_index = self.photo_list.highlighted - 1 # Adjust for 'Ingest Photo' at top - + if self.photo_list.highlighted is None or photo_index < 0 or photo_index >= len(photos): self.notify("Photo no longer available", severity="error") return - + selected_photo = photos[photo_index] - + # Schedule async deletion self.call_later(self._async_delete_photo, selected_photo) else: @@ -566,21 +566,21 @@ class EditEntryScreen(Screen): if not self.sidebar_focused or not self.sidebar_visible: self.notify("Use F8 to open the sidebar first.", severity="warning") return - + if self.photo_list.highlighted is None: self.notify("No photo selected", severity="warning") return - + # Adjust index because of 'Ingest Photo' at the top photo_index = self.photo_list.highlighted - 1 - + photos = self._load_photos_for_diary(self.diary_id) if photo_index < 0 or photo_index >= len(photos): self.notify("No photo selected", severity="warning") return - + selected_photo = photos[photo_index] - + # Open edit photo modal self.app.push_screen( EditPhotoModal(photo=selected_photo), @@ -596,13 +596,13 @@ class EditEntryScreen(Screen): # Get the selected photo with adjusted index photos = self._load_photos_for_diary(self.diary_id) photo_index = self.photo_list.highlighted - 1 # Adjust for 'Ingest Photo' at top - + if self.photo_list.highlighted is None or photo_index < 0 or photo_index >= len(photos): self.notify("Photo no longer available", severity="error") return - + selected_photo = photos[photo_index] - + # Schedule async update self.call_later(self._async_update_photo, selected_photo, result) @@ -644,7 +644,7 @@ class EditEntryScreen(Screen): text = self.text_entry.text pattern = r"\[\[photo::(\w{8})\]\]" short_hashes_in_text = set(re.findall(pattern, text)) - + self._load_photos_for_diary(self.diary_id) if not short_hashes_in_text: return [] # Nenhuma referência, operação válida. @@ -655,7 +655,7 @@ class EditEntryScreen(Screen): if len(found_photos) == 1: linked_photos.append(found_photos[0]) else: - self.notify(f"❌ Erro: Referência '[{short_hash}]' é inválida ou ambígua!", severity="error", timeout=10) + self.notify(f"❌ Erro: Referência '\[{short_hash}\\]' é inválida ou ambígua!", severity="error", timeout=10) return None # Aborta a operação return linked_photos @@ -664,25 +664,25 @@ class EditEntryScreen(Screen): """Handles photo selection in the sidebar""" if not self.sidebar_visible: return - + # Handle "Ingest Photo" option if event.option_index == 0: # First option is "Ingest Photo" self.action_ingest_new_photo() return - + photos = self._load_photos_for_diary(self.diary_id) if not photos: return - + # Adjust index because of 'Ingest Photo' at the top photo_index = event.option_index - 1 if photo_index >= len(photos): return - + selected_photo = photos[photo_index] photo_hash = selected_photo.photo_hash[:8] self.notify(f"Selected photo: {selected_photo.name} \\[{photo_hash}\\]") - + # Update photo info with details including hash photo_details = f"📷 {selected_photo.name}\n" photo_details += f"🔗 {photo_hash}\n" @@ -693,7 +693,7 @@ class EditEntryScreen(Screen): photo_details += f"[b]Reference formats:[/b]\n" photo_details += f"\\[\\[photo:{selected_photo.name}:{photo_hash}\\]\\]\n" photo_details += f"\\[\\[photo::{photo_hash}\\]\\]" - + self.photo_info.update(photo_details) def on_text_area_changed(self, event) -> None: @@ -701,12 +701,12 @@ class EditEntryScreen(Screen): if (hasattr(self, 'text_entry') and not self.text_entry.read_only and not getattr(self, '_updating_display', False) and hasattr(self, '_original_content')): current_content = self.text_entry.text - - + + # Check for a photo reference pattern # self._check_photo_reference(current_content) # Temporarily disabled - + if current_content != self._original_content: if not self.has_unsaved_changes: self.has_unsaved_changes = True @@ -832,37 +832,42 @@ class EditEntryScreen(Screen): self._update_sub_header() def action_save(self) -> None: - """Saves the current entry""" - self._get_all_references() - self._validate_references() + """Salva a entrada após validar e coletar as fotos referenciadas.""" + photos_to_link = self._get_linked_photos_from_text() + + if photos_to_link is None: + self.notify("⚠️ Salvamento cancelado. Corrija as referências de fotos.", severity="error") + return + + content = self.text_entry.text.strip() if self.is_new_entry: - content = self.text_entry.text.strip() if not content: self.notify("Empty entry cannot be saved") return - - # Schedule async creation - self.call_later(self._async_create_entry, content) + # Passe a lista de fotos para o método de criação + self.call_later(self._async_create_entry, content, photos_to_link) else: - # Schedule async update - self.call_later(self._async_update_entry) + # Passe a lista de fotos para o método de atualização + self.call_later(self._async_update_entry, content, photos_to_link) - async def _async_create_entry(self, content: str): - """Creates a new entry asynchronously""" + async def _async_create_entry(self, content: str, photos_to_link: List[Photo]): + """Cria uma nova entrada e associa as fotos referenciadas.""" + service_manager = self.app.service_manager + db_session = service_manager.get_db_session() try: - service_manager = self.app.service_manager entry_service = service_manager.get_entry_service() - current_date = datetime.now() - + # O service.create deve criar o objeto em memória, mas NÃO fazer o commit ainda. new_entry = entry_service.create( travel_diary_id=self.diary_id, title=self.new_entry_title, text=content, - date=current_date + date=datetime.now(), + photos=photos_to_link ) if new_entry: + # A partir daqui, é só atualizar a UI como você já fazia self.entries.append(new_entry) self.entries.sort(key=lambda x: x.id) @@ -873,74 +878,47 @@ class EditEntryScreen(Screen): self.is_new_entry = False self.has_unsaved_changes = False - self._original_content = new_entry.text + self._original_content = new_entry.text # Pode ser o texto com hashes curtos self.new_entry_title = "New Entry" self.next_entry_id = max(entry.id for entry in self.entries) + 1 self._update_entry_display() - self.notify(f"New entry '{new_entry.title}' saved successfully!") + self.notify(f"✅ Nova entrada '{new_entry.title}' salva com sucesso!") else: - self.notify("Error creating entry") + self.notify("❌ Erro ao criar entrada") except Exception as e: - self.notify(f"Error creating entry: {str(e)}") + self.notify(f"❌ Erro ao criar entrada: {str(e)}") + + async def _async_update_entry(self, updated_content: str, photos_to_link: List[Photo]): + """Atualiza uma entrada existente e sua associação de fotos.""" + service_manager = self.app.service_manager - async def _async_update_entry(self): - """Updates the current entry asynchronously""" try: if not self.entries: - self.notify("No entry to update") + self.notify("Nenhuma entrada para atualizar") return - + entry_service = service_manager.get_entry_service() current_entry = self.entries[self.current_entry_index] - updated_content = self.text_entry.text - - updated_entry = Entry( + entry_result : Entry = Entry( title=current_entry.title, text=updated_content, + photos=photos_to_link, date=current_entry.date, - travel_diary_id=current_entry.fk_travel_diary_id, - id=current_entry.id + travel_diary_id=self.diary_id + ) + entry_service.update(current_entry, entry_result) - service_manager = self.app.service_manager - entry_service = service_manager.get_entry_service() - - result = entry_service.update(current_entry, updated_entry) - - if result: - current_entry.text = updated_content - self.has_unsaved_changes = False - self._original_content = updated_content - self._update_sub_header() - self.notify(f"Entry '{current_entry.title}' saved successfully!") - else: - self.notify("Error updating entry") + # A partir daqui, é só atualizar a UI + self.has_unsaved_changes = False + self._original_content = updated_content # Pode ser o texto com hashes curtos + self._update_sub_header() + self.notify(f"✅ Entrada '{current_entry.title}' salva com sucesso!") except Exception as e: - self.notify(f"Error updating entry: {str(e)}") - def _get_all_references(self): - - text_content = self.text_entry.text - matches = re.findall("(\[\[photo::?(?:\w|\s)*\]\])", text_content) - for match in matches: - if re.match(r"\[\[photo::\w+\]\]", match): - if {'type': 'hash','value':match.replace("[[photo::", "").replace("]]", "").strip()} not in self.references: - self.references.append( - {'type': 'hash', 'value': match.replace("[[photo::", "").replace("]]", "").strip()}) - elif re.match(r"\[\[photo:\w+\]\]", match): - if {'type': 'name', 'value': match.replace("[[photo:", "").replace("]]", "").strip()} not in self.references: - self.references.append( - {'type': 'name', 'value': match.replace("[[photo:", "").replace("]]", "").strip()}) - else: - self.references.append({'type': 'unknown', 'value': match}) - self.notify(f"🔍 Referências encontradas: {str(self.references)}", markup=False) - - def _validate_references(self): - photo_service = self.app.service_manager.get_photo_service() - for reference in self.references: - if reference['type'] == 'hash': - pass + # Desfaz as mudanças em caso de erro + self.notify(f"❌ Erro ao atualizar entrada: {str(e)}") def on_key(self, event): print("DEBUG: on_key called with", event.key, "sidebar_focused:", self.sidebar_focused, "sidebar_visible:", self.sidebar_visible) @@ -988,4 +966,4 @@ class EditEntryScreen(Screen): # Tab: insert tab elif self.focused is self.text_entry and event.key == "tab": self.text_entry.insert('\t') - event.stop() \ No newline at end of file + event.stop() \ No newline at end of file From a9756b058ee98f60ac22a621069b971ceb9bb379 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Sat, 5 Jul 2025 06:48:24 -0300 Subject: [PATCH 8/9] Changed some texts to be in english and also removed some debug messages. --- src/pilgrim/ui/screens/edit_entry_screen.py | 28 ++++++++++----------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/pilgrim/ui/screens/edit_entry_screen.py b/src/pilgrim/ui/screens/edit_entry_screen.py index 3897fcb..53fa326 100644 --- a/src/pilgrim/ui/screens/edit_entry_screen.py +++ b/src/pilgrim/ui/screens/edit_entry_screen.py @@ -655,7 +655,7 @@ class EditEntryScreen(Screen): if len(found_photos) == 1: linked_photos.append(found_photos[0]) else: - self.notify(f"❌ Erro: Referência '\[{short_hash}\\]' é inválida ou ambígua!", severity="error", timeout=10) + self.notify(f"❌ Error: the reference: '\[{short_hash}\\]' is not valid or ambiguous", severity="error", timeout=10) return None # Aborta a operação return linked_photos @@ -836,7 +836,7 @@ class EditEntryScreen(Screen): photos_to_link = self._get_linked_photos_from_text() if photos_to_link is None: - self.notify("⚠️ Salvamento cancelado. Corrija as referências de fotos.", severity="error") + self.notify("⚠️ Saving was canceled ", severity="error") return content = self.text_entry.text.strip() @@ -883,12 +883,12 @@ class EditEntryScreen(Screen): self.next_entry_id = max(entry.id for entry in self.entries) + 1 self._update_entry_display() - self.notify(f"✅ Nova entrada '{new_entry.title}' salva com sucesso!") + self.notify(f"✅ New Entry: '{new_entry.title}' Successfully saved") else: - self.notify("❌ Erro ao criar entrada") + self.notify("❌ Error creating the Entry") except Exception as e: - self.notify(f"❌ Erro ao criar entrada: {str(e)}") + self.notify(f"❌ Error creating the entry: {str(e)}") async def _async_update_entry(self, updated_content: str, photos_to_link: List[Photo]): """Atualiza uma entrada existente e sua associação de fotos.""" @@ -896,7 +896,7 @@ class EditEntryScreen(Screen): try: if not self.entries: - self.notify("Nenhuma entrada para atualizar") + self.notify("No Entry to update") return entry_service = service_manager.get_entry_service() current_entry = self.entries[self.current_entry_index] @@ -914,31 +914,31 @@ class EditEntryScreen(Screen): self.has_unsaved_changes = False self._original_content = updated_content # Pode ser o texto com hashes curtos self._update_sub_header() - self.notify(f"✅ Entrada '{current_entry.title}' salva com sucesso!") + self.notify(f"✅ Entry: '{current_entry.title}' sucesfully saved") except Exception as e: # Desfaz as mudanças em caso de erro - self.notify(f"❌ Erro ao atualizar entrada: {str(e)}") + self.notify(f"❌ Error on updating the entry:: {str(e)}") def on_key(self, event): - print("DEBUG: on_key called with", event.key, "sidebar_focused:", self.sidebar_focused, "sidebar_visible:", self.sidebar_visible) + # Sidebar contextual shortcuts if self.sidebar_focused and self.sidebar_visible: - print("DEBUG: Processing sidebar shortcut for key:", event.key) + if event.key == "i": - print("DEBUG: Calling action_insert_photo") + self.action_insert_photo() event.stop() elif event.key == "n": - print("DEBUG: Calling action_ingest_new_photo") + self.action_ingest_new_photo() event.stop() elif event.key == "d": - print("DEBUG: Calling action_delete_photo") + self.action_delete_photo() event.stop() elif event.key == "e": - print("DEBUG: Calling action_edit_photo") + self.action_edit_photo() event.stop() # Shift+Tab: remove indent From 394f813f6fbaf9fc6a29f5e8b02a653ff0df5514 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Sat, 5 Jul 2025 07:26:47 -0300 Subject: [PATCH 9/9] Added few checks to the reference to avoid malformed or invalid reference from being saved. --- src/pilgrim/ui/screens/edit_entry_screen.py | 92 +++++++++++++++++---- 1 file changed, 77 insertions(+), 15 deletions(-) diff --git a/src/pilgrim/ui/screens/edit_entry_screen.py b/src/pilgrim/ui/screens/edit_entry_screen.py index 53fa326..0a1d13d 100644 --- a/src/pilgrim/ui/screens/edit_entry_screen.py +++ b/src/pilgrim/ui/screens/edit_entry_screen.py @@ -637,28 +637,90 @@ class EditEntryScreen(Screen): def _get_linked_photos_from_text(self) -> Optional[List[Photo]]: """ - Valida as referências de hash curto no texto contra o cache em memória. - - Retorna uma lista de objetos Photo se todas as referências forem válidas. - - Retorna None se UMA ÚNICA referência for inválida ou ambígua. + Validates photo references in the text against the memory cache. + Checks for: + - Malformed references + - Incorrect hash length + - Invalid or ambiguous hashes + Returns a list of unique photos (no duplicates even if referenced multiple times). """ text = self.text_entry.text - pattern = r"\[\[photo::(\w{8})\]\]" - short_hashes_in_text = set(re.findall(pattern, text)) + + # First check for malformed references + malformed_pattern = r"\[\[photo::([^\]]*)\](?!\])" # Missing ] at the end + malformed_matches = re.findall(malformed_pattern, text) + if malformed_matches: + for match in malformed_matches: + self.notify(f"❌ Malformed reference: '\\[\\[photo::{match}\\]' - Missing closing '\\]'", severity="error", timeout=10) + return None + + # Look for incorrect format references + invalid_format = r"\[\[photo:[^:\]]+\]\]" # [[photo:something]] without :: + invalid_matches = re.findall(invalid_format, text) + if invalid_matches: + for match in invalid_matches: + escaped_match = match.replace("[", "\\[").replace("]", "\\]") + self.notify(f"❌ Invalid format: '{escaped_match}' - Use '\\[\\[photo::hash\\]\\]'", severity="error", timeout=10) + return None + + # Now look for all references to validate + pattern = r"\[\[photo::([^\]]+)\]\]" + # Use set to get unique references only + all_refs = set(re.findall(pattern, text)) + + if not all_refs: + return [] # No references, valid operation + self._load_photos_for_diary(self.diary_id) - if not short_hashes_in_text: - return [] # Nenhuma referência, operação válida. - linked_photos: List[Photo] = [] - for short_hash in short_hashes_in_text: - found_photos = [p for p in self.cached_photos if p.photo_hash.startswith(short_hash)] + processed_hashes = set() # Keep track of processed hashes to avoid duplicates - if len(found_photos) == 1: - linked_photos.append(found_photos[0]) + for ref in all_refs: + # Skip if we already processed this hash + if ref in processed_hashes: + continue + + # Validate hash length + if len(ref) != 8: + self.notify( + f"❌ Invalid hash: '{ref}' - Must be exactly 8 characters long", + severity="error", + timeout=10 + ) + return None + + # Validate if contains only valid hexadecimal characters + if not re.match(r"^[0-9A-Fa-f]{8}$", ref): + self.notify( + f"❌ Invalid hash: '{ref}' - Use only hexadecimal characters (0-9, A-F)", + severity="error", + timeout=10 + ) + return None + + # Search for photos matching the hash + found_photos = [p for p in self.cached_photos if p.photo_hash.startswith(ref)] + + if len(found_photos) == 0: + self.notify( + f"❌ Hash not found: '{ref}' - No photo matches this hash", + severity="error", + timeout=10 + ) + return None + elif len(found_photos) > 1: + self.notify( + f"❌ Ambiguous hash: '{ref}' - Matches multiple photos", + severity="error", + timeout=10 + ) + return None else: - self.notify(f"❌ Error: the reference: '\[{short_hash}\\]' is not valid or ambiguous", severity="error", timeout=10) - return None # Aborta a operação + linked_photos.append(found_photos[0]) + processed_hashes.add(ref) # Mark this hash as processed - return linked_photos + # Convert list to set and back to list to ensure uniqueness of photos + return list(set(linked_photos)) def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: """Handles photo selection in the sidebar"""