Merge pull request #12 from gmbrax/feat/photo-sidebar-tui

Feat/photo sidebar tui
This commit is contained in:
Gustavo Henrique Miranda 2025-06-29 22:27:14 -03:00 committed by GitHub
commit 43919cabb9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 326 additions and 59 deletions

2
.gitignore vendored
View File

@ -1,6 +1,8 @@
# Database files
database.db
.build-vend/
dist_nuitka/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]

View File

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

View File

@ -12,10 +12,13 @@ class Database:
echo=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):
Base.metadata.create_all(self.engine)
def session(self):
return self._session_maker()
def get_db(self):
return self.session()
return self._session_maker()

View File

@ -1,6 +1,8 @@
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 pilgrim.models.photo_in_entry import photo_entry_association
@ -12,7 +14,7 @@ class Photo(Base):
id = Column(Integer, primary_key=True)
filepath = Column(String)
name = Column(String)
addition_date = Column(String)
addition_date = Column(DateTime, default=datetime.now)
caption = Column(String)
entries = relationship(
"Entry",
@ -22,10 +24,16 @@ 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, **kw: Any):
def __init__(self, filepath, name, addition_date=None, caption=None, entries=None, fk_travel_diary_id=None, **kw: Any):
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.addition_date = addition_date
self.addition_date = addition_date if addition_date is not None else datetime.now()
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
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._next_id += 1
return new_photo
@ -24,19 +31,18 @@ class PhotoServiceMock(PhotoService):
def read_all(self) -> List[Photo]:
return list(self.mock_data.values())
def update(self, photo_id: Photo, photo_dst: Photo) -> Photo | None:
item_to_update:Photo = self.mock_data.get(photo_id)
def update(self, photo_src: Photo, photo_dst: Photo) -> Photo | None:
item_to_update: Photo = self.mock_data.get(photo_src.id)
if item_to_update:
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.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\
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.entries.extend(photo_dst.entries)
item_to_update.addition_date = photo_dst.addition_date if photo_dst.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
if photo_dst.entries:
item_to_update.entries = photo_dst.entries
return item_to_update
return None
def delete(self, photo_id: int) -> Photo | None:
return self.mock_data.pop(photo_id, None)
def delete(self, photo_src: Photo) -> Photo | None:
return self.mock_data.pop(photo_src.id, None)

View File

@ -1,5 +1,6 @@
from pathlib import Path
from typing import List
from datetime import datetime
from pilgrim.models.photo import Photo
@ -9,11 +10,25 @@ class PhotoService:
def __init__(self, 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()
if not travel_diary:
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.commit()
self.session.refresh(new_photo)
@ -25,24 +40,37 @@ class PhotoService:
def read_all(self) -> List[Photo]:
return self.session.query(Photo).all()
def update(self,photo_src:Photo,photo_dst:Photo) -> Photo | None:
original:Photo = self.read_by_id(photo_src.id)
def update(self, photo_src: Photo, photo_dst: Photo) -> Photo | None:
original: Photo = self.read_by_id(photo_src.id)
if original:
original.filepath = photo_dst.filepath
original.name = photo_dst.name
original.addition_date = photo_dst.addition_date
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.refresh(original)
return original
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)
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.commit()
self.session.refresh(excluded)
return excluded
return deleted_photo
return None

View File

@ -13,6 +13,8 @@ from pilgrim.models.entry import Entry
from pilgrim.models.travel_diary import TravelDiary
from pilgrim.models.photo import Photo
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.rename_entry_modal import RenameEntryModal
@ -275,7 +277,7 @@ class EditEntryScreen(Screen):
"[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]: Focus Sidebar/Editor"
"[b][yellow]F9[/yellow][/b]: Switch focus (if needed)"
)
self.help_text.update(help_text)
@ -301,10 +303,13 @@ class EditEntryScreen(Screen):
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! 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"
)
self._sidebar_opened_once = True
@ -337,7 +342,7 @@ class EditEntryScreen(Screen):
def action_insert_photo(self):
"""Insert selected photo into text"""
if not self.sidebar_focused or not self.sidebar_visible:
self.notify("Use F9 to focus the sidebar before using this shortcut.", severity="warning")
self.notify("Use F8 to open the sidebar first.", severity="warning")
return
# Get selected photo
@ -345,11 +350,15 @@ class EditEntryScreen(Screen):
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 self.photo_list.highlighted >= len(photos):
if photo_index < 0 or photo_index >= len(photos):
self.notify("No photo selected", severity="warning")
return
selected_photo = photos[self.photo_list.highlighted]
selected_photo = photos[photo_index]
# Insert photo reference into text
photo_ref = f"\n[📷 {selected_photo.name}]({selected_photo.filepath})\n"
@ -367,7 +376,7 @@ class EditEntryScreen(Screen):
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 F9 to focus the sidebar before using this shortcut.", severity="warning")
self.notify("Use F8 to open the sidebar first.", severity="warning")
return
# Open add photo modal
@ -382,8 +391,10 @@ class EditEntryScreen(Screen):
self.notify("Add photo cancelled")
return
# Schedule async creation
self.call_later(self._async_create_photo, result)
# Photo was already created in the modal, just refresh the sidebar
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):
"""Creates a new photo asynchronously"""
@ -415,24 +426,46 @@ class EditEntryScreen(Screen):
def action_delete_photo(self):
"""Delete selected photo"""
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
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 self.photo_list.highlighted >= len(photos):
if photo_index < 0 or photo_index >= len(photos):
self.notify("No photo selected", severity="warning")
return
selected_photo = photos[self.photo_list.highlighted]
selected_photo = photos[photo_index]
# Confirm deletion
self.notify(f"Deleting photo: {selected_photo.name}")
# Open confirm delete modal
self.app.push_screen(
ConfirmDeleteModal(photo=selected_photo),
self.handle_delete_photo_result
)
# Schedule async deletion
self.call_later(self._async_delete_photo, selected_photo)
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
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):
"""Deletes a photo asynchronously"""
@ -456,18 +489,22 @@ class EditEntryScreen(Screen):
def action_edit_photo(self):
"""Edit selected photo using modal"""
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
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 self.photo_list.highlighted >= len(photos):
if photo_index < 0 or photo_index >= len(photos):
self.notify("No photo selected", severity="warning")
return
selected_photo = photos[self.photo_list.highlighted]
selected_photo = photos[photo_index]
# Open edit photo modal
self.app.push_screen(
@ -481,13 +518,15 @@ class EditEntryScreen(Screen):
self.notify("Edit photo cancelled")
return
# Get the selected photo
# Get the selected photo with adjusted index
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")
return
selected_photo = photos[self.photo_list.highlighted]
selected_photo = photos[photo_index]
# Schedule async update
self.call_later(self._async_update_photo, selected_photo, result)
@ -504,7 +543,7 @@ class EditEntryScreen(Screen):
name=photo_data["name"],
addition_date=original_photo.addition_date,
caption=photo_data["caption"],
entries=original_photo.entries,
entries=original_photo.entries if original_photo.entries is not None else [],
id=original_photo.id
)

View File

@ -1,4 +1,5 @@
import os
from pathlib import Path
from textual.app import ComposeResult
from textual.screen import Screen
from textual.widgets import Static, Input, Button
@ -47,15 +48,58 @@ class AddPhotoModal(Screen):
if not filepath.strip() or not name.strip():
self.notify("File path and name are required", severity="error")
return
self.result = {
# Try to create the photo in the database
self.call_later(self._async_create_photo, {
"filepath": filepath.strip(),
"name": name.strip(),
"caption": caption.strip() if caption.strip() else None
}
self.dismiss()
})
elif event.button.id == "cancel-button":
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:
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

@ -0,0 +1,32 @@
from textual.app import ComposeResult
from textual.screen import Screen
from textual.widgets import Static, Button
from textual.containers import Container, Horizontal
from pilgrim.models.photo import Photo
class ConfirmDeleteModal(Screen):
"""Modal for confirming photo deletion"""
def __init__(self, photo: Photo):
super().__init__()
self.photo = photo
self.result = None
def compose(self) -> ComposeResult:
yield Container(
Static("🗑️ Confirm Deletion", classes="ConfirmDeleteModal-Title"),
Static(f"Are you sure you want to delete the photo '{self.photo.name}'?", classes="ConfirmDeleteModal-Message"),
Static("This action cannot be undone.", classes="ConfirmDeleteModal-Warning"),
Horizontal(
Button("Delete", variant="error", id="delete-button", classes="ConfirmDeleteModal-Button"),
Button("Cancel", variant="default", id="cancel-button", classes="ConfirmDeleteModal-Button"),
classes="ConfirmDeleteModal-Buttons"
),
classes="ConfirmDeleteModal-Dialog"
)
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "delete-button":
self.result = True
self.dismiss(True)
elif event.button.id == "cancel-button":
self.dismiss(False)

View File

@ -0,0 +1,68 @@
from textual.app import ComposeResult
from textual.screen import Screen
from textual.widgets import Static, Input, Button
from textual.containers import Container, Horizontal
from pilgrim.models.photo import Photo
class EditPhotoModal(Screen):
"""Modal for editing an existing photo (name and caption only)"""
def __init__(self, photo: Photo):
super().__init__()
self.photo = photo
self.result = None
def compose(self) -> ComposeResult:
yield Container(
Static("✏️ Edit Photo", classes="EditPhotoModal-Title"),
Static("File path (read-only):", classes="EditPhotoModal-Label"),
Input(
value=self.photo.filepath,
id="filepath-input",
classes="EditPhotoModal-Input",
disabled=True
),
Static("Photo name:", classes="EditPhotoModal-Label"),
Input(
value=self.photo.name,
placeholder="Enter photo name...",
id="name-input",
classes="EditPhotoModal-Input"
),
Static("Caption (optional):", classes="EditPhotoModal-Label"),
Input(
value=self.photo.caption or "",
placeholder="Enter caption...",
id="caption-input",
classes="EditPhotoModal-Input"
),
Horizontal(
Button("Save Changes", id="save-button", classes="EditPhotoModal-Button"),
Button("Cancel", id="cancel-button", classes="EditPhotoModal-Button"),
classes="EditPhotoModal-Buttons"
),
classes="EditPhotoModal-Dialog"
)
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "save-button":
name = self.query_one("#name-input", Input).value
caption = self.query_one("#caption-input", Input).value
if not name.strip():
self.notify("Photo name is required", severity="error")
return
# Return the updated photo data
self.result = {
"filepath": self.photo.filepath, # Keep original filepath
"name": name.strip(),
"caption": caption.strip() if caption.strip() else None
}
self.dismiss(self.result)
elif event.button.id == "cancel-button":
self.dismiss()
def on_mount(self) -> None:
"""Focus on the name input when modal opens"""
self.query_one("#name-input", Input).focus()

View File

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