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
This commit is contained in:
Gustavo Henrique Santos Souza de Miranda 2025-07-02 12:48:12 -03:00
parent 2973620573
commit d80825d3a1
3 changed files with 327 additions and 78 deletions

View File

@ -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")
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)
# 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
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:
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

View File

@ -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:

View File

@ -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"),