mirror of https://github.com/gmbrax/Pilgrim.git
Merge pull request #17 from gmbrax/feat/photo-reference-system
Feat/photo reference system
This commit is contained in:
commit
eb511ad756
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,13 @@ 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
|
||||
self.references = []
|
||||
self.cached_photos = []
|
||||
|
||||
# Main header
|
||||
self.header = Header(name="Pilgrim v6", classes="EditEntryScreen-header")
|
||||
|
|
@ -105,9 +117,28 @@ 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 _get_cursor_position(self) -> tuple:
|
||||
"""Get the 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 compose(self) -> ComposeResult:
|
||||
print("DEBUG: EditEntryScreen COMPOSE", getattr(self, 'sidebar_visible', None))
|
||||
yield self.header
|
||||
|
|
@ -130,6 +161,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,88 +281,108 @@ 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:
|
||||
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 the '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 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
|
||||
for photo in photos:
|
||||
self.photo_list.add_option(f"📷 {photo.name}")
|
||||
# Add photos to the list with hash
|
||||
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")
|
||||
|
||||
# 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 a 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]:
|
||||
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"""
|
||||
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:
|
||||
|
|
@ -341,11 +393,12 @@ 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
|
||||
|
||||
# Get selected photo
|
||||
# Get a selected photo
|
||||
if self.photo_list.highlighted is None:
|
||||
self.notify("No photo selected", severity="warning")
|
||||
return
|
||||
|
|
@ -353,25 +406,42 @@ 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 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 the 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"""
|
||||
|
|
@ -380,10 +450,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."""
|
||||
|
|
@ -452,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
|
||||
|
||||
|
|
@ -560,34 +635,140 @@ 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]]:
|
||||
"""
|
||||
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
|
||||
|
||||
# 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)
|
||||
linked_photos: List[Photo] = []
|
||||
processed_hashes = set() # Keep track of processed hashes to avoid duplicates
|
||||
|
||||
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:
|
||||
linked_photos.append(found_photos[0])
|
||||
processed_hashes.add(ref) # Mark this hash as processed
|
||||
|
||||
# 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"""
|
||||
if not self.sidebar_visible:
|
||||
return
|
||||
# If 'Ingest Photo' is selected (always index 0)
|
||||
if event.option_index == 0:
|
||||
|
||||
# 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 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 = 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"
|
||||
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 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
|
||||
|
|
@ -599,7 +780,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
|
||||
|
|
@ -713,35 +894,42 @@ class EditEntryScreen(Screen):
|
|||
self._update_sub_header()
|
||||
|
||||
def action_save(self) -> None:
|
||||
"""Saves the current entry"""
|
||||
"""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("⚠️ Saving was canceled ", 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)
|
||||
|
||||
|
|
@ -752,66 +940,67 @@ 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"✅ New Entry: '{new_entry.title}' Successfully saved")
|
||||
else:
|
||||
self.notify("Error creating entry")
|
||||
self.notify("❌ Error creating the Entry")
|
||||
|
||||
except Exception as e:
|
||||
self.notify(f"Error creating entry: {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."""
|
||||
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("No Entry to update")
|
||||
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"✅ Entry: '{current_entry.title}' sucesfully saved")
|
||||
|
||||
except Exception as e:
|
||||
self.notify(f"Error updating entry: {str(e)}")
|
||||
# Desfaz as mudanças em caso de erro
|
||||
self.notify(f"❌ Error on updating the entry:: {str(e)}")
|
||||
|
||||
def on_key(self, event):
|
||||
|
||||
# Sidebar contextual shortcuts
|
||||
if self.sidebar_focused and self.sidebar_visible:
|
||||
|
||||
if event.key == "i":
|
||||
|
||||
self.action_insert_photo()
|
||||
event.stop()
|
||||
elif event.key == "n":
|
||||
|
||||
self.action_ingest_new_photo()
|
||||
event.stop()
|
||||
elif event.key == "d":
|
||||
|
||||
self.action_delete_photo()
|
||||
event.stop()
|
||||
elif event.key == "e":
|
||||
|
||||
self.action_edit_photo()
|
||||
event.stop()
|
||||
# Shift+Tab: remove indent
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Reference in New Issue