Compare commits

...

12 Commits

Author SHA1 Message Date
Gustavo Henrique Miranda b1e83aabbb
Merge pull request #18 from gmbrax/feat/XDG-Compliance
Applying the XDG Compliance Branch to the Stagin Branch
2025-07-05 09:23:52 -03:00
Gustavo Henrique Miranda eb511ad756
Merge pull request #17 from gmbrax/feat/photo-reference-system
Feat/photo reference system
2025-07-05 09:19:02 -03:00
Gustavo Henrique Santos Souza de Miranda d114357d50 Changed the base directory where the data generated by the program to be at the home directory and also added a check to ensure the creation of the program directory 2025-07-05 07:29:09 -03:00
Gustavo Henrique Santos Souza de Miranda 394f813f6f Added few checks to the reference to avoid malformed or invalid reference from being saved. 2025-07-05 07:26:47 -03:00
Gustavo Henrique Santos Souza de Miranda a9756b058e Changed some texts to be in english and also removed some debug messages. 2025-07-05 06:48:24 -03:00
Gustavo Henrique Santos Souza de Miranda e492e2c248 Finished The basic Reference System it now parses the references added by the sidebar or written but only saves if the reference is valid 2025-07-05 05:54:18 -03:00
Gustavo Henrique Santos Souza de Miranda 0ec480a851 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.
2025-07-04 19:14:16 -03:00
Gustavo Henrique Santos Souza de Miranda 3754a68a80 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 2025-07-03 23:10:13 -03:00
Gustavo Henrique Santos Souza de Miranda 183e0bc8c7 Started implementing the proper hash on the photo sidebar 2025-07-03 21:09:09 -03:00
Gustavo Henrique Santos Souza de Miranda 8cc42e390a Added Hash capability to the model photo.py and also updated the service to create hashes and update hashes 2025-07-03 21:08:24 -03:00
Gustavo Henrique Santos Souza de Miranda d47259fc68 Removed Stale methods on edit_entry_screen.py 2025-07-03 01:33:31 -03:00
Gustavo Henrique Santos Souza de Miranda d80825d3a1 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
2025-07-02 12:48:12 -03:00
8 changed files with 480 additions and 178 deletions

View File

@ -1,16 +1,47 @@
from pilgrim.database import Database
from pilgrim.service.servicemanager import ServiceManager
from pilgrim.ui.ui import UIApp
from pathlib import Path
import os
import sys
class Application:
def __init__(self):
self.config_dir = self._ensure_config_directory()
self.database = Database()
session = self.database.session()
session_manager = ServiceManager()
session_manager.set_session(session)
self.ui = UIApp(session_manager)
def _ensure_config_directory(self) -> Path:
"""
Ensures the ~/.pilgrim directory exists and has the correct permissions.
Creates it if it doesn't exist.
Returns the Path object for the config directory.
"""
home = Path.home()
config_dir = home / ".pilgrim"
try:
# Create directory if it doesn't exist
config_dir.mkdir(exist_ok=True)
# Ensure correct permissions (rwx for user only)
os.chmod(config_dir, 0o700)
# Create an empty .gitignore if it doesn't exist
gitignore = config_dir / ".gitignore"
if not gitignore.exists():
gitignore.write_text("*\n")
return config_dir
except Exception as e:
print(f"Error setting up Pilgrim configuration directory: {str(e)}", file=sys.stderr)
sys.exit(1)
def run(self):
self.database.create()
self.ui.run()

View File

@ -1,14 +1,42 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from pathlib import Path
import os
import shutil
Base = declarative_base()
def get_database_path() -> Path:
"""
Get the database file path following XDG Base Directory specification.
Creates the directory if it doesn't exist.
"""
# Get home directory
home = Path.home()
# Create .pilgrim directory if it doesn't exist
pilgrim_dir = home / ".pilgrim"
pilgrim_dir.mkdir(exist_ok=True)
# Database file path
db_path = pilgrim_dir / "database.db"
# If database doesn't exist in new location but exists in current directory,
# migrate it
if not db_path.exists():
current_db = Path("database.db")
if current_db.exists():
shutil.copy2(current_db, db_path)
print(f"Database migrated from {current_db} to {db_path}")
return db_path
class Database:
def __init__(self):
db_path = get_database_path()
self.engine = create_engine(
"sqlite:///database.db",
f"sqlite:///{db_path}",
echo=False,
connect_args={"check_same_thread": False},
)

View File

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

View File

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

View File

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

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,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
@ -123,20 +154,21 @@ 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
def update_diary_info(self):
"""Updates diary information"""
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
@ -149,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):
@ -179,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):
@ -249,141 +281,184 @@ 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")
# 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(self.cached_photos)} photos in diary")
def _load_photos_for_diary(self, diary_id: int) -> List[Photo]:
# 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):
"""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
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:
self.text_entry.focus()
# Update footer after focus change
self._update_footer_context()
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
# 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]
# 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 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
self.notify(f"Inserted photo: {selected_photo.name}")
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"
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"""
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
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."""
@ -403,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"],
@ -428,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),
@ -452,16 +527,16 @@ 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
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:
@ -491,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),
@ -521,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)
@ -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
@ -839,4 +1028,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()
event.stop()

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