Improved the photo sidebar and add the add_photo_modal.py to add photos and the file_picker_modal.py to select the proper file to be ingested, also changed photo.py and photo_service.py to receive a datatime and not a string representing the addition date

This commit is contained in:
Gustavo Henrique Santos Souza de Miranda 2025-06-29 21:38:33 -03:00
parent 84b0397263
commit f84da2c934
10 changed files with 226 additions and 58 deletions

3
.gitignore vendored
View File

@ -1,3 +1,6 @@
database.db database.db
__pycache__ __pycache__
/.idea/ /.idea/
.build-vend
.venv
dist_nuitka

View File

@ -1,5 +1,4 @@
from pilgrim.database import Database from pilgrim.database import Database
from pilgrim.service.mocks.service_manager_mock import ServiceManagerMock
from pilgrim.service.servicemanager import ServiceManager from pilgrim.service.servicemanager import ServiceManager
from pilgrim.ui.ui import UIApp from pilgrim.ui.ui import UIApp

View File

@ -12,10 +12,13 @@ class Database:
echo=False, echo=False,
connect_args={"check_same_thread": False}, connect_args={"check_same_thread": False},
) )
self.session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False) self._session_maker = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
def create(self): def create(self):
Base.metadata.create_all(self.engine) Base.metadata.create_all(self.engine)
def session(self):
return self._session_maker()
def get_db(self): def get_db(self):
return self.session() return self._session_maker()

View File

@ -1,6 +1,8 @@
from typing import Any from typing import Any
from datetime import datetime
from pathlib import Path
from sqlalchemy import Column, Integer, String, ForeignKey from sqlalchemy import Column, Integer, String, ForeignKey, DateTime
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from pilgrim.models.photo_in_entry import photo_entry_association from pilgrim.models.photo_in_entry import photo_entry_association
@ -12,7 +14,7 @@ class Photo(Base):
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
filepath = Column(String) filepath = Column(String)
name = Column(String) name = Column(String)
addition_date = Column(String) addition_date = Column(DateTime, default=datetime.now)
caption = Column(String) caption = Column(String)
entries = relationship( entries = relationship(
"Entry", "Entry",
@ -22,10 +24,16 @@ class Photo(Base):
fk_travel_diary_id = Column(Integer, ForeignKey("travel_diaries.id"),nullable=False) fk_travel_diary_id = Column(Integer, ForeignKey("travel_diaries.id"),nullable=False)
def __init__(self, filepath, name, addition_date=None, caption=None, entries=None, **kw: Any): def __init__(self, filepath, name, addition_date=None, caption=None, entries=None, fk_travel_diary_id=None, **kw: Any):
super().__init__(**kw) super().__init__(**kw)
self.filepath = filepath # Convert Path to string if needed
if isinstance(filepath, Path):
self.filepath = str(filepath)
else:
self.filepath = filepath
self.name = name self.name = name
self.addition_date = addition_date self.addition_date = addition_date if addition_date is not None else datetime.now()
self.caption = caption self.caption = caption
self.entries = entries 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

@ -12,7 +12,14 @@ class PhotoServiceMock(PhotoService):
self._next_id = 1 self._next_id = 1
def create(self, filepath: Path, name: str, travel_diary_id, addition_date=None, caption=None) -> Photo | None: def create(self, filepath: Path, name: str, travel_diary_id, addition_date=None, caption=None) -> Photo | None:
new_photo = Photo(filepath, name, addition_date=addition_date, caption=caption) new_photo = Photo(
filepath=filepath,
name=name,
addition_date=addition_date,
caption=caption,
fk_travel_diary_id=travel_diary_id
)
new_photo.id = self._next_id
self.mock_data[self._next_id] = new_photo self.mock_data[self._next_id] = new_photo
self._next_id += 1 self._next_id += 1
return new_photo return new_photo
@ -24,19 +31,18 @@ class PhotoServiceMock(PhotoService):
def read_all(self) -> List[Photo]: def read_all(self) -> List[Photo]:
return list(self.mock_data.values()) return list(self.mock_data.values())
def update(self, photo_id: Photo, photo_dst: Photo) -> Photo | None: def update(self, photo_src: Photo, photo_dst: Photo) -> Photo | None:
item_to_update:Photo = self.mock_data.get(photo_id) item_to_update: Photo = self.mock_data.get(photo_src.id)
if item_to_update: if item_to_update:
item_to_update.filepath = photo_dst.filepath if photo_dst.filepath else item_to_update.filepath item_to_update.filepath = photo_dst.filepath if photo_dst.filepath else item_to_update.filepath
item_to_update.name = photo_dst.name if photo_dst.name else item_to_update.name item_to_update.name = photo_dst.name if photo_dst.name else item_to_update.name
item_to_update.caption = photo_dst.caption if photo_dst.caption else item_to_update.caption item_to_update.caption = photo_dst.caption if photo_dst.caption else item_to_update.caption
item_to_update.addition_date = photo_dst.addition_date if photo_dst.addition_date\ item_to_update.addition_date = photo_dst.addition_date if photo_dst.addition_date else item_to_update.addition_date
else item_to_update.addition_date item_to_update.fk_travel_diary_id = photo_dst.fk_travel_diary_id if photo_dst.fk_travel_diary_id else item_to_update.fk_travel_diary_id
item_to_update.fk_travel_diary_id = photo_dst.fk_travel_diary_id if photo_dst.fk_travel_diary_id \ if photo_dst.entries:
else item_to_update.fk_travel_diary_id item_to_update.entries = photo_dst.entries
item_to_update.entries.extend(photo_dst.entries)
return item_to_update return item_to_update
return None return None
def delete(self, photo_id: int) -> Photo | None: def delete(self, photo_src: Photo) -> Photo | None:
return self.mock_data.pop(photo_id, None) return self.mock_data.pop(photo_src.id, None)

View File

@ -1,5 +1,6 @@
from pathlib import Path from pathlib import Path
from typing import List from typing import List
from datetime import datetime
from pilgrim.models.photo import Photo from pilgrim.models.photo import Photo
@ -9,11 +10,25 @@ class PhotoService:
def __init__(self, session): def __init__(self, session):
self.session = session self.session = session
def create(self, filepath:Path, name:str, travel_diary_id, addition_date=None, caption=None, ) -> Photo | None: 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() travel_diary = self.session.query(TravelDiary).filter(TravelDiary.id == travel_diary_id).first()
if not travel_diary: if not travel_diary:
return None return None
new_photo = Photo(filepath, name, addition_date=addition_date, caption=caption)
# Convert addition_date string to datetime if needed
if isinstance(addition_date, str):
try:
addition_date = datetime.strptime(addition_date, "%Y-%m-%d %H:%M:%S")
except ValueError:
addition_date = None
new_photo = Photo(
filepath=filepath,
name=name,
caption=caption,
fk_travel_diary_id=travel_diary_id,
addition_date=addition_date
)
self.session.add(new_photo) self.session.add(new_photo)
self.session.commit() self.session.commit()
self.session.refresh(new_photo) self.session.refresh(new_photo)
@ -25,24 +40,37 @@ class PhotoService:
def read_all(self) -> List[Photo]: def read_all(self) -> List[Photo]:
return self.session.query(Photo).all() return self.session.query(Photo).all()
def update(self,photo_src:Photo,photo_dst:Photo) -> Photo | None: def update(self, photo_src: Photo, photo_dst: Photo) -> Photo | None:
original:Photo = self.read_by_id(photo_src.id) original: Photo = self.read_by_id(photo_src.id)
if original: if original:
original.filepath = photo_dst.filepath original.filepath = photo_dst.filepath
original.name = photo_dst.name original.name = photo_dst.name
original.addition_date = photo_dst.addition_date original.addition_date = photo_dst.addition_date
original.caption = photo_dst.caption original.caption = photo_dst.caption
original.entries.extend(photo_dst.entries) if photo_dst.entries and len(photo_dst.entries) > 0:
if original.entries is None:
original.entries = []
original.entries = photo_dst.entries # Replace instead of extend
self.session.commit() self.session.commit()
self.session.refresh(original) self.session.refresh(original)
return original return original
return None return None
def delete(self, photo_src:Photo) -> Photo | None: def delete(self, photo_src: Photo) -> Photo | None:
excluded = self.read_by_id(photo_src.id) excluded = self.read_by_id(photo_src.id)
if excluded: if excluded:
# Store photo data before deletion
deleted_photo = Photo(
filepath=excluded.filepath,
name=excluded.name,
addition_date=excluded.addition_date,
caption=excluded.caption,
fk_travel_diary_id=excluded.fk_travel_diary_id,
id=excluded.id
)
self.session.delete(excluded) self.session.delete(excluded)
self.session.commit() self.session.commit()
self.session.refresh(excluded)
return excluded return deleted_photo
return None return None

View File

@ -13,6 +13,8 @@ from pilgrim.models.entry import Entry
from pilgrim.models.travel_diary import TravelDiary from pilgrim.models.travel_diary import TravelDiary
from pilgrim.models.photo import Photo from pilgrim.models.photo import Photo
from pilgrim.ui.screens.modals.add_photo_modal import AddPhotoModal from pilgrim.ui.screens.modals.add_photo_modal import AddPhotoModal
from pilgrim.ui.screens.modals.edit_photo_modal import EditPhotoModal
from pilgrim.ui.screens.modals.confirm_delete_modal import ConfirmDeleteModal
from pilgrim.ui.screens.modals.file_picker_modal import FilePickerModal from pilgrim.ui.screens.modals.file_picker_modal import FilePickerModal
from pilgrim.ui.screens.rename_entry_modal import RenameEntryModal from pilgrim.ui.screens.rename_entry_modal import RenameEntryModal
@ -275,7 +277,7 @@ class EditEntryScreen(Screen):
"[b][green]e[/green][/b]: Edit selected photo\n" "[b][green]e[/green][/b]: Edit selected photo\n"
"[b][yellow]Tab[/yellow][/b]: Back to editor\n" "[b][yellow]Tab[/yellow][/b]: Back to editor\n"
"[b][yellow]F8[/yellow][/b]: Show/hide sidebar\n" "[b][yellow]F8[/yellow][/b]: Show/hide sidebar\n"
"[b][yellow]F9[/yellow][/b]: Focus Sidebar/Editor" "[b][yellow]F9[/yellow][/b]: Switch focus (if needed)"
) )
self.help_text.update(help_text) self.help_text.update(help_text)
@ -301,10 +303,13 @@ class EditEntryScreen(Screen):
if self.sidebar_visible: if self.sidebar_visible:
self.sidebar.display = True self.sidebar.display = True
self._update_sidebar_content() 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 # Notification when opening the sidebar for the first time
if not self._sidebar_opened_once: if not self._sidebar_opened_once:
self.notify( self.notify(
"Sidebar opened! Context-specific shortcuts are always visible in the sidebar help panel.", "Sidebar opened and focused! Use the shortcuts shown in the help panel.",
severity="info" severity="info"
) )
self._sidebar_opened_once = True self._sidebar_opened_once = True
@ -337,7 +342,7 @@ class EditEntryScreen(Screen):
def action_insert_photo(self): def action_insert_photo(self):
"""Insert selected photo into text""" """Insert selected photo into text"""
if not self.sidebar_focused or not self.sidebar_visible: if not self.sidebar_focused or not self.sidebar_visible:
self.notify("Use F9 to focus the sidebar before using this shortcut.", severity="warning") self.notify("Use F8 to open the sidebar first.", severity="warning")
return return
# Get selected photo # Get selected photo
@ -345,11 +350,15 @@ class EditEntryScreen(Screen):
self.notify("No photo selected", severity="warning") self.notify("No photo selected", severity="warning")
return 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) photos = self._load_photos_for_diary(self.diary_id)
if self.photo_list.highlighted >= len(photos): if photo_index < 0 or photo_index >= len(photos):
self.notify("No photo selected", severity="warning")
return return
selected_photo = photos[self.photo_list.highlighted] selected_photo = photos[photo_index]
# Insert photo reference into text # Insert photo reference into text
photo_ref = f"\n[📷 {selected_photo.name}]({selected_photo.filepath})\n" photo_ref = f"\n[📷 {selected_photo.name}]({selected_photo.filepath})\n"
@ -367,7 +376,7 @@ class EditEntryScreen(Screen):
def action_ingest_new_photo(self): def action_ingest_new_photo(self):
"""Ingest a new photo using modal""" """Ingest a new photo using modal"""
if not self.sidebar_focused or not self.sidebar_visible: if not self.sidebar_focused or not self.sidebar_visible:
self.notify("Use F9 to focus the sidebar before using this shortcut.", severity="warning") self.notify("Use F8 to open the sidebar first.", severity="warning")
return return
# Open add photo modal # Open add photo modal
@ -382,8 +391,10 @@ class EditEntryScreen(Screen):
self.notify("Add photo cancelled") self.notify("Add photo cancelled")
return return
# Schedule async creation # Photo was already created in the modal, just refresh the sidebar
self.call_later(self._async_create_photo, result) if self.sidebar_visible:
self._update_sidebar_content()
self.notify(f"Photo '{result['name']}' added successfully!")
async def _async_create_photo(self, photo_data: dict): async def _async_create_photo(self, photo_data: dict):
"""Creates a new photo asynchronously""" """Creates a new photo asynchronously"""
@ -415,24 +426,46 @@ class EditEntryScreen(Screen):
def action_delete_photo(self): def action_delete_photo(self):
"""Delete selected photo""" """Delete selected photo"""
if not self.sidebar_focused or not self.sidebar_visible: if not self.sidebar_focused or not self.sidebar_visible:
self.notify("Use F9 to focus the sidebar before using this shortcut.", severity="warning") self.notify("Use F8 to open the sidebar first.", severity="warning")
return return
if self.photo_list.highlighted is None: if self.photo_list.highlighted is None:
self.notify("No photo selected", severity="warning") self.notify("No photo selected", severity="warning")
return 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) photos = self._load_photos_for_diary(self.diary_id)
if self.photo_list.highlighted >= len(photos): if photo_index < 0 or photo_index >= len(photos):
self.notify("No photo selected", severity="warning")
return return
selected_photo = photos[self.photo_list.highlighted] selected_photo = photos[photo_index]
# Confirm deletion # Open confirm delete modal
self.notify(f"Deleting photo: {selected_photo.name}") self.app.push_screen(
ConfirmDeleteModal(photo=selected_photo),
self.handle_delete_photo_result
)
# Schedule async deletion def handle_delete_photo_result(self, result: bool) -> None:
self.call_later(self._async_delete_photo, selected_photo) """Callback that processes the delete photo modal result."""
if result:
# 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 deletion
self.call_later(self._async_delete_photo, selected_photo)
else:
self.notify("Delete cancelled")
async def _async_delete_photo(self, photo: Photo): async def _async_delete_photo(self, photo: Photo):
"""Deletes a photo asynchronously""" """Deletes a photo asynchronously"""
@ -456,18 +489,22 @@ class EditEntryScreen(Screen):
def action_edit_photo(self): def action_edit_photo(self):
"""Edit selected photo using modal""" """Edit selected photo using modal"""
if not self.sidebar_focused or not self.sidebar_visible: if not self.sidebar_focused or not self.sidebar_visible:
self.notify("Use F9 to focus the sidebar before using this shortcut.", severity="warning") self.notify("Use F8 to open the sidebar first.", severity="warning")
return return
if self.photo_list.highlighted is None: if self.photo_list.highlighted is None:
self.notify("No photo selected", severity="warning") self.notify("No photo selected", severity="warning")
return 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) photos = self._load_photos_for_diary(self.diary_id)
if self.photo_list.highlighted >= len(photos): if photo_index < 0 or photo_index >= len(photos):
self.notify("No photo selected", severity="warning")
return return
selected_photo = photos[self.photo_list.highlighted] selected_photo = photos[photo_index]
# Open edit photo modal # Open edit photo modal
self.app.push_screen( self.app.push_screen(
@ -481,13 +518,15 @@ class EditEntryScreen(Screen):
self.notify("Edit photo cancelled") self.notify("Edit photo cancelled")
return return
# Get the selected photo # Get the selected photo with adjusted index
photos = self._load_photos_for_diary(self.diary_id) photos = self._load_photos_for_diary(self.diary_id)
if self.photo_list.highlighted is None or self.photo_list.highlighted >= len(photos): 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") self.notify("Photo no longer available", severity="error")
return return
selected_photo = photos[self.photo_list.highlighted] selected_photo = photos[photo_index]
# Schedule async update # Schedule async update
self.call_later(self._async_update_photo, selected_photo, result) self.call_later(self._async_update_photo, selected_photo, result)
@ -504,7 +543,7 @@ class EditEntryScreen(Screen):
name=photo_data["name"], name=photo_data["name"],
addition_date=original_photo.addition_date, addition_date=original_photo.addition_date,
caption=photo_data["caption"], caption=photo_data["caption"],
entries=original_photo.entries, entries=original_photo.entries if original_photo.entries is not None else [],
id=original_photo.id id=original_photo.id
) )

View File

@ -1,4 +1,5 @@
import os import os
from pathlib import Path
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Static, Input, Button from textual.widgets import Static, Input, Button
@ -47,15 +48,58 @@ class AddPhotoModal(Screen):
if not filepath.strip() or not name.strip(): if not filepath.strip() or not name.strip():
self.notify("File path and name are required", severity="error") self.notify("File path and name are required", severity="error")
return return
self.result = {
# Try to create the photo in the database
self.call_later(self._async_create_photo, {
"filepath": filepath.strip(), "filepath": filepath.strip(),
"name": name.strip(), "name": name.strip(),
"caption": caption.strip() if caption.strip() else None "caption": caption.strip() if caption.strip() else None
} })
self.dismiss()
elif event.button.id == "cancel-button": elif event.button.id == "cancel-button":
self.dismiss() self.dismiss()
async def _async_create_photo(self, photo_data: dict):
"""Creates a new photo asynchronously using PhotoService"""
try:
service_manager = self.app.service_manager
photo_service = service_manager.get_photo_service()
new_photo = photo_service.create(
filepath=Path(photo_data["filepath"]),
name=photo_data["name"],
travel_diary_id=self.diary_id,
caption=photo_data["caption"]
)
if new_photo:
self.notify(f"Photo '{new_photo.name}' added successfully!")
# 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
}
self.dismiss(self.result)
else:
self.notify("Error creating photo in database", severity="error")
except Exception as e:
self.notify(f"Error creating photo: {str(e)}", severity="error")
def handle_file_picker_result(self, result: str | None) -> None: def handle_file_picker_result(self, result: str | None) -> None:
if result: if result:
self.query_one("#filepath-input", Input).value = result # Set the filepath input value
filepath_input = self.query_one("#filepath-input", Input)
filepath_input.value = result
# Trigger the input change event to update the UI
filepath_input.refresh()
# Auto-fill the name field with the filename (without extension)
filename = Path(result).stem
name_input = self.query_one("#name-input", Input)
if not name_input.value.strip():
name_input.value = filename
name_input.refresh()
else:
# User cancelled the file picker
self.notify("File selection cancelled", severity="information")

View File

@ -25,7 +25,6 @@ class FilePickerModal(Screen):
self.start_path = Path(start_path or os.getcwd()) self.start_path = Path(start_path or os.getcwd())
# Start one level up to make navigation easier # Start one level up to make navigation easier
self.current_path = self.start_path.parent self.current_path = self.start_path.parent
self.result = None
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Container( yield Container(
@ -45,8 +44,8 @@ class FilePickerModal(Screen):
# Check if it's an image file # Check if it's an image file
image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'} image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'}
if file_path.suffix.lower() in image_extensions: if file_path.suffix.lower() in image_extensions:
self.result = str(file_path) # Return the file path as result
self.dismiss() self.dismiss(str(file_path))
else: else:
self.notify("Please select an image file", severity="warning") self.notify("Please select an image file", severity="warning")
@ -63,4 +62,5 @@ class FilePickerModal(Screen):
tree.path = str(self.current_path) tree.path = str(self.current_path)
tree.reload() tree.reload()
elif event.button.id == "cancel-button": elif event.button.id == "cancel-button":
self.dismiss() # Return None to indicate cancellation
self.dismiss(None)

View File

@ -589,3 +589,41 @@ Screen.-modal {
border: solid $accent; border: solid $accent;
margin: 1; margin: 1;
} }
/* ConfirmDeleteModal styles */
.ConfirmDeleteModal-Dialog {
layout: vertical;
width: 60%;
height: auto;
background: $surface;
border: thick $error;
padding: 2 4;
align: center middle;
}
.ConfirmDeleteModal-Title {
text-align: center;
text-style: bold;
color: $error;
margin-bottom: 1;
}
.ConfirmDeleteModal-Message {
text-align: center;
color: $text;
margin-bottom: 1;
}
.ConfirmDeleteModal-Warning {
text-align: center;
color: $warning;
text-style: italic;
margin-bottom: 2;
}
.ConfirmDeleteModal-Buttons {
width: 1fr;
height: auto;
align: center middle;
padding-top: 1;
}
.ConfirmDeleteModal-Button {
margin: 0 1;
width: 1fr;
}