Compare commits

..
Author SHA1 Message Date
Gustavo Henrique Santos Souza de Miranda 2fba487b59 Refactor the photo sidebar to be its own class 2025-07-25 14:55:41 -03:00
Gustavo Henrique Miranda fad21863c7 Merge pull request #86 from gmbrax/test/backup-service-unit-test
Add fixture and tests for the backup service
2025-07-25 00:20:51 -03:00
Gustavo Henrique Miranda 9a5d112a26 Merge branch 'staging' into test/backup-service-unit-test 2025-07-25 00:20:35 -03:00
Gustavo Henrique Miranda 4af2a47606 Merge pull request #85 from gmbrax/test/traveldiaryservice-unit-test
Add the tests for the new methods of deleting
2025-07-25 00:17:47 -03:00
Gustavo Henrique Santos Souza de Miranda 3d926993e8 Add the tests for the new methods of deleting 2025-07-25 00:13:08 -03:00
Gustavo Henrique Miranda ed55d33bc6 Merge pull request #84 from gmbrax/test/entryservice-unit-test
Add the tests for the new methods of deleting
2025-07-25 00:01:00 -03:00
Gustavo Henrique Santos Souza de Miranda 819a2c7b6b Add the tests for the new methods of deleting 2025-07-24 23:36:02 -03:00
Gustavo Henrique Miranda 92ee47cff3 Merge pull request #83 from gmbrax/fix/add-photos-list-to-entry-model
Fix to add a photo list to the constructor of the entry model
2025-07-24 23:20:01 -03:00
Gustavo Henrique Mirandaandcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> 8d45b4361e Update src/pilgrim/models/entry.py
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-07-24 23:18:34 -03:00
Gustavo Henrique Santos Souza de Miranda 3db39eac71 Fix to add a photo list to the constructor of the entry model 2025-07-24 23:13:01 -03:00
Gustavo Henrique Santos Souza de Miranda 53e8b36a42 Add fixture and tests for the backup service 2025-07-24 22:56:43 -03:00
Gustavo Henrique Miranda 7faab93dfe Update CHANGELOG.md
Add the 0.0.5 changes into the changelog
2025-07-24 21:52:28 -03:00
Gustavo Henrique Miranda 34821bfedb Merge pull request #82 from gmbrax/refactor/remove-mocks
Remove the unused mock services
2025-07-24 21:12:32 -03:00
8 changed files with 450 additions and 382 deletions
+55 -34
View File
@@ -6,63 +6,84 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve
## Unreleased
## Planned
* Installation Method 1 (repository compilation)
* Restore from backup functionality
* Organization of trips by date, location, or theme
* Enhanced photo management features
* Search functionality
* Export features
* Testing implementation
* UI Testing with Textual Pilot
## [0.0.5] - 2025-07-24
### Added
* **Backup Feature:** Introduced a full backup feature to export the database and all photos to a single ZIP file.
* **Diary Settings Screen:** Added a dedicated settings screen per diary, accessible with the 's' key from the diary list.
* **Advanced Deletion Options:** The new settings screen allows for deleting the entire diary, all of its entries, or all of its photos.
* **Confirmation Modals:** Added confirmation modals that require the user to type the diary's name to proceed with critical delete operations, preventing accidental data loss.
* **Comprehensive Test Suite:** Implemented a robust unit and integration test suite for the entire backend (services, models, utils, application), achieving high code coverage and ensuring stability.
### Changed
* **Architectural Refactor:** Service methods were refactored to be transactional, allowing for more complex operations to be executed safely and atomically.
* **Configuration:** The "auto-open diary on startup" setting is now managed and persisted via the `config.toml` file.
* **Project Structure:** Standardized project metadata and build configuration in `pyproject.toml` and converted all imports to use a consistent absolute path structure.
### Fixed
* **Directory Name Sanitization:** Fixed a bug where directory names were not correctly sanitized, now properly handling accented and special characters by using transliteration.
* **Data Integrity:** Enforced `NOT NULL` constraints on `title` and `date` fields for entries in the database, preventing the creation of entries with invalid data.
### Removed
* **Obsolete Mocks:** Removed old and unused mock service files from the production codebase.
## [0.0.4] - 2025-07-19
### Added
* Support for creating new diaries asynchronously, with an option to automatically open the newly created diary
* Unified "Enter" key support for saving or creating diaries across relevant modals
* Automatic diary list refresh when returning to the diary screen
* Application configuration management with a new centralized config system
* Database location and initialization now configurable via the new config manager
* Automatic migration of database file to the configuration directory
* Display of database URL on application startup for transparency
* Duplicate photo detection before photo creation to prevent redundant entries
* Photo hash indexing to improve photo lookup performance
* Support for creating new diaries asynchronously, with an option to automatically open the newly created diary.
* Unified "Enter" key support for saving or creating diaries across relevant modals.
* Automatic diary list refresh when returning to the diary screen.
* Application configuration management with a new centralized config system.
* Database location and initialization now configurable via the new config manager.
* Automatic migration of database file to the configuration directory.
* Display of database URL on application startup for transparency.
* Duplicate photo detection before photo creation to prevent redundant entries.
* Photo hash indexing to improve photo lookup performance.
### Changed
* Enhanced feedback and validation when editing or creating diary names
* Streamlined and unified save logic for diary modals, reducing duplicated behavior
* About screen now displays the actual installed application version dynamically
* Sidebar and photo-related UI text updated to remove emoji icons for a cleaner appearance
* Sidebar layout and scrolling behavior improved for better usability
* Photo hash generation now relies on existing service-provided hashes instead of local computation
* Enhanced feedback and validation when editing or creating diary names.
* Streamlined and unified save logic for diary modals, reducing duplicated behavior.
* About screen now displays the actual installed application version dynamically.
* Sidebar and photo-related UI text updated to remove emoji icons for a cleaner appearance.
* Sidebar layout and scrolling behavior improved for better usability.
* Photo hash generation now relies on existing service-provided hashes instead of local computation.
### Improved
* Enhanced feedback and validation when editing or creating diary names
* Streamlined and unified save logic for diary modals, reducing duplicated behavior
* Sidebar layout and scrolling behavior for better usability
* Enhanced feedback and validation when editing or creating diary names.
* Streamlined and unified save logic for diary modals, reducing duplicated behavior.
* Sidebar layout and scrolling behavior for better usability.
## [0.0.3] - 2025-07-07
### Changed
* Removed the dependency on textual-dev from pyproject.toml
* Removed the dependency on textual-dev from pyproject.toml.
## [0.0.2] - 2025-07-07
### Changed
* Changed the license in pyproject.toml to BSD
* Changed the license in pyproject.toml to BSD.
## [0.0.1] - 2025-07-06
### Added
* Initial alpha release of Pilgrim travel diary application
* Create and edit travel diaries
* Create and edit diary entries
* Photo ingestion system
* Photo addition and reference via sidebar
* Text User Interface (TUI) built with Textual framework
* Pre-compiled binary installation method (Method 2)
* Support for Linux operating systems
* Basic project documentation (README)
* Initial alpha release of Pilgrim travel diary application.
* Create and edit travel diaries.
* Create and edit diary entries.
* Photo ingestion system.
* Photo addition and reference via sidebar.
* Text User Interface (TUI) built with Textual framework.
* Pre-compiled binary installation method (Method 2).
* Support for Linux operating systems.
* Basic project documentation (README).
### Known Issues
* Installation Method 1 not yet implemented
* No testing suite implemented yet
* Some features may be unstable in an alpha version
* Installation Method 1 not yet implemented.
* No testing suite implemented yet.
* Some features may be unstable in an alpha version.
+6 -2
View File
@@ -1,5 +1,6 @@
from typing import Any
from typing import Any, List
from pilgrim.models.photo import Photo
from pilgrim.models.photo_in_entry import photo_entry_association
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime
from sqlalchemy.orm import relationship
@@ -21,9 +22,12 @@ class Entry(Base):
fk_travel_diary_id = Column(Integer, ForeignKey("travel_diaries.id"), nullable=False)
travel_diary = relationship("TravelDiary", back_populates="entries")
def __init__(self, title: str, text: str, date: str, travel_diary_id: int, **kw: Any):
def __init__(self, title: str, text: str, date: Any, travel_diary_id: int, photos: List[Photo] = None, **kw: Any):
super().__init__(**kw)
self.title = title
self.text = text
self.date = date
self.fk_travel_diary_id = travel_diary_id
if photos is not None:
self.photos = photos
+69 -341
View File
@@ -9,12 +9,15 @@ from pilgrim.ui.screens.modals.add_photo_modal import AddPhotoModal
from pilgrim.ui.screens.modals.confirm_delete_modal import ConfirmDeleteModal
from pilgrim.ui.screens.modals.edit_photo_modal import EditPhotoModal
from pilgrim.ui.screens.rename_entry_modal import RenameEntryModal
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Container, Horizontal, Vertical
from textual.screen import Screen
from textual.widgets import Header, Footer, Static, TextArea, OptionList
from pilgrim.ui.screens.widgets.photo_sidebar import PhotoSidebar
class EditEntryScreen(Screen):
TITLE = "Pilgrim - Edit"
@@ -84,26 +87,7 @@ class EditEntryScreen(Screen):
# Text area
self.text_entry = TextArea(id="text_entry", classes="EditEntryScreen-text-entry")
# Sidebar widgets
self.sidebar_title = Static("Photos", classes="EditEntryScreen-sidebar-title")
self.photo_list = OptionList(id="photo_list", classes="EditEntryScreen-sidebar-photo-list")
self.photo_info = Static("", classes="EditEntryScreen-sidebar-photo-info")
self.help_text = Static("", classes="EditEntryScreen-sidebar-help")
# Sidebar container: photo list and info in a flexible container, help_text fixed at bottom
self.sidebar_content = Vertical(
self.photo_list,
self.photo_info,
id="sidebar_content",
classes="EditEntryScreen-sidebar-content"
)
self.sidebar = Vertical(
self.sidebar_title,
self.sidebar_content,
self.help_text, # Always at the bottom, never scrolls
id="sidebar",
classes="EditEntryScreen-sidebar"
)
self.sidebar = PhotoSidebar()
# Main container
self.main = Container(
@@ -281,91 +265,75 @@ class EditEntryScreen(Screen):
self.call_after_refresh(self._finish_display_update)
def _update_sidebar_content(self):
"""Updates the sidebar content with photos for the current diary"""
try:
self._load_photos_for_diary(self.diary_id)
# Clear existing options safely
self.photo_list.clear_options()
# Add the 'Ingest Photo' option at the top
self.photo_list.add_option("➕ Ingest Photo")
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 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(self.cached_photos)} photos in diary")
# 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]📝 Photo References[/b]\n"
"\\[\\[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()
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)
return self.cached_photos
except Exception as e:
self.notify(f"Error loading photos: {str(e)}")
return []
def action_toggle_sidebar(self):
"""Toggles the sidebar visibility"""
try:
self.sidebar_visible = not self.sidebar_visible
self.sidebar.display = 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()
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
# Chama o novo worker síncrono
self.run_worker(self._load_and_update_sidebar_worker, exclusive=True, thread=True)
# 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.focus()
else:
self.text_entry.focus()
def _load_and_update_sidebar_worker(self):
"""
Worker que roda em uma thread para carregar as fotos sem travar a UI.
"""
# A lógica de buscar os dados pode ser feita diretamente ,
# pois já estamos em uma thread de segundo plano.
photo_service = self.app.service_manager.get_photo_service()
all_photos = photo_service.read_all()
self.cached_photos = [p for p in all_photos if p.fk_travel_diary_id == self.diary_id]
# MUDANÇA 2: Não podemos atualizar a UI diretamente de outra thread.
# Usamos 'call_from_thread' para agendar a atualização de forma segura.
self.app.call_from_thread(self.sidebar.update_photo_list, self.cached_photos)
def on_photo_sidebar_insert_photo_reference(self, message: PhotoSidebar.InsertPhotoReference):
"""Reage à mensagem para inserir uma referência de foto."""
photo_ref = f"[[photo::{message.photo_hash}]]"
self.text_entry.insert(photo_ref)
self.text_entry.focus()
def on_photo_sidebar_ingest_new_photo(self, message: PhotoSidebar.IngestNewPhoto):
"""Reage à mensagem para ingerir uma nova foto."""
def refresh_sidebar_after_add(result):
if result:
self.notify(f"Photo '{result['name']}' added successfully!")
self.run_worker(self._load_and_update_sidebar)
self.app.push_screen(
AddPhotoModal(diary_id=self.diary_id),
refresh_sidebar_after_add
)
def on_photo_sidebar_edit_photo(self, message: PhotoSidebar.EditPhoto):
"""Reage à mensagem para editar uma foto."""
def refresh_sidebar_after_edit(result):
if result:
self.notify(f"Photo '{result['name']}' updated successfully!")
self.run_worker(self._load_and_update_sidebar)
self.app.push_screen(
EditPhotoModal(photo=message.photo),
refresh_sidebar_after_edit
)
def on_photo_sidebar_delete_photo(self, message: PhotoSidebar.DeletePhoto):
"""Reage à mensagem para deletar uma foto."""
def refresh_sidebar_after_delete(result):
if result:
self.notify(f"Photo '{message.photo.name}' deleted successfully!")
self.run_worker(self._load_and_update_sidebar)
self.app.push_screen(
ConfirmDeleteModal(photo=message.photo),
refresh_sidebar_after_delete
)
def action_toggle_focus(self):
"""Toggles focus between editor and sidebar"""
@@ -383,248 +351,8 @@ class EditEntryScreen(Screen):
# 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 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
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 = 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 += "[b]Reference formats:[/b]\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
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."""
if result is None:
self.notify("Add photo cancelled")
return
# 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"""
try:
service_manager = self.app.service_manager
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"],
travel_diary_id=self.diary_id,
addition_date=current_date,
caption=photo_data["caption"]
)
if new_photo:
self.notify(f"Photo '{new_photo.name}' added successfully!")
# Refresh sidebar content
if self.sidebar_visible:
self._update_sidebar_content()
else:
self.notify("Error creating photo")
except Exception as e:
self.notify(f"Error creating photo: {str(e)}")
def action_delete_photo(self):
"""Delete selected photo"""
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),
self.handle_delete_photo_result
)
def handle_delete_photo_result(self, result: bool) -> None:
"""Callback that processes the delete photo modal result."""
if result:
# 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:
self.notify("Delete cancelled")
async def _async_delete_photo(self, photo: Photo):
"""Deletes a photo asynchronously"""
try:
service_manager = self.app.service_manager
photo_service = service_manager.get_photo_service()
result = photo_service.delete(photo)
if result:
self.notify(f"Photo '{photo.name}' deleted successfully!")
# Refresh sidebar content
if self.sidebar_visible:
self._update_sidebar_content()
else:
self.notify("Error deleting photo")
except Exception as e:
self.notify(f"Error deleting photo: {str(e)}")
def action_edit_photo(self):
"""Edit selected 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
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),
self.handle_edit_photo_result
)
def handle_edit_photo_result(self, result: dict | None) -> None:
"""Callback that processes the edit photo modal result."""
if result is None:
self.notify("Edit photo cancelled")
return
# 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)
async def _async_update_photo(self, original_photo: Photo, photo_data: dict):
"""Updates a photo asynchronously"""
try:
service_manager = self.app.service_manager
photo_service = service_manager.get_photo_service()
# Create updated photo object
updated_photo = Photo(
filepath=photo_data["filepath"],
name=photo_data["name"],
addition_date=original_photo.addition_date,
caption=photo_data["caption"],
entries=original_photo.entries if original_photo.entries is not None else [],
id=original_photo.id,
photo_hash=original_photo.photo_hash,
)
result = photo_service.update(original_photo, updated_photo)
if result:
self.notify(f"Photo '{updated_photo.name}' updated successfully!")
# Refresh sidebar content
if self.sidebar_visible:
self._update_sidebar_content()
else:
self.notify("Error updating photo")
except Exception as e:
self.notify(f"Error updating photo: {str(e)}")
def _get_linked_photos_from_text(self) -> Optional[List[Photo]]:
"""
@@ -0,0 +1,125 @@
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.message import Message
from textual.widgets import Static, OptionList
from textual.widgets.option_list import Option
from pilgrim.models.photo import Photo
class PhotoSidebar(Vertical):
class PhotoSelected(Message):
def __init__(self, photo_id: int) -> None:
self.photo_id = photo_id
super().__init__()
class InsertPhotoReference(Message):
def __init__(self, photo_hash: str) -> None:
self.photo_hash = photo_hash
super().__init__()
class IngestNewPhoto(Message):
pass
class EditPhoto(Message):
def __init__(self, photo_id: int) -> None:
self.photo_id = photo_id
super().__init__()
class DeletePhoto(Message):
def __init__(self, photo_id: int) -> None:
self.photo_id = photo_id
super().__init__()
def __init__(self, **kwargs):
super().__init__(id="sidebar", classes="EditEntryScreen-sidebar", **kwargs)
self.cached_photos = []
def compose(self) -> ComposeResult:
yield Static("Photos", classes="EditEntryScreen-sidebar-title")
yield Vertical(
OptionList(id="photo_list", classes="EditEntryScreen-sidebar-photo-list"),
Static("", id="photo_info", classes="EditEntryScreen-sidebar-photo-info"),
id="sidebar_content",
classes="EditEntryScreen-sidebar-content"
)
yield Static("", id="help_text", classes="EditEntryScreen-sidebar-help")
def _get_selected_photo(self) -> Photo | None:
"""Busca a foto selecionada na lista."""
photo_list = self.query_one("#photo_list", OptionList)
if photo_list.highlighted is None:
return None
option = photo_list.get_option_at_index(photo_list.highlighted)
if option.id == "ingest":
return None
try:
photo_id = int(str(option.id).split("_")[1])
return next((p for p in self.cached_photos if p.id == photo_id), None)
except (IndexError, ValueError):
return None
def update_photo_list(self, photos: list[Photo]):
"""Método público para a tela principal popular a lista de fotos."""
self.cached_photos = photos
photo_list = self.query_one("#photo_list", OptionList)
photo_list.clear_options()
# Adiciona opção de ingest
photo_list.add_option(Option("➕ Ingest New Photo", id="ingest"))
# Adiciona fotos
for photo in self.cached_photos:
photo_hash = str(photo.photo_hash)[:8]
photo_list.add_option(
Option(f"{photo.name} [{photo_hash}]", id=f"photo_{photo.id}")
)
# Atualiza info
photo_info = self.query_one("#photo_info", Static)
if not self.cached_photos:
photo_info.update("No photos in diary")
else:
photo_info.update(f"{len(self.cached_photos)} photos in diary")
def on_option_list_option_selected(self, event: OptionList.OptionSelected):
"""Lida com a seleção de um item na lista."""
if event.option.id == "ingest":
self.post_message(self.IngestNewPhoto())
else:
# Emite PhotoSelected para outras opções
try:
photo_id = int(str(event.option.id).split("_")[1])
self.post_message(self.PhotoSelected(photo_id))
except (IndexError, ValueError):
pass
def on_key(self, event) -> None:
"""Lida com os atalhos de teclado quando a barra lateral está em foco."""
key = event.key
if key == "n":
self.post_message(self.IngestNewPhoto())
event.stop()
return
selected_photo = self._get_selected_photo()
if not selected_photo:
return
if key == "i":
self.post_message(self.InsertPhotoReference(selected_photo.photo_hash[:8]))
event.stop()
elif key == "e":
self.post_message(self.EditPhoto(selected_photo.id)) # Corrigido: passa o ID
event.stop()
elif key == "d":
self.post_message(self.DeletePhoto(selected_photo.id)) # Corrigido: passa o ID
event.stop()
def on_mount(self) -> None:
"""Inicializa a lista com a opção padrão."""
photo_list = self.query_one("#photo_list", OptionList)
photo_list.add_option(Option("➕ Ingest New Photo", id="ingest"))
+75
View File
@@ -1,10 +1,18 @@
from pathlib import Path
from unittest.mock import patch
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from datetime import datetime
from pilgrim.models.entry import Entry
from pilgrim.database import Base
from pilgrim.models.travel_diary import TravelDiary
from pilgrim.models.entry import Entry
from pilgrim.models.photo import Photo
from pilgrim.utils import DirectoryManager
# Todos os imports necessários para as fixtures devem estar aqui
# ...
@@ -62,3 +70,70 @@ def session_with_photos(session_with_one_diary):
session.commit()
return session, [photo1, photo2]
@pytest.fixture
def backup_test_env_files_only(tmp_path):
fake_config_dir = tmp_path / "config"
fake_diaries_root = tmp_path / "diaries"
fake_db_path = fake_config_dir / "database.db"
fake_config_dir.mkdir()
fake_diaries_root.mkdir()
with patch.object(DirectoryManager, 'get_database_path', return_value=fake_db_path), \
patch.object(DirectoryManager, 'get_config_directory', return_value=fake_config_dir), \
patch.object(DirectoryManager, 'get_diaries_root', return_value=fake_diaries_root):
engine = create_engine(f"sqlite:///{fake_db_path}")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
diary = TravelDiary(name="Viagem de Teste", directory_name="viagem_de_teste")
session.add(diary)
session.commit()
photo_file_path_str = str(fake_diaries_root / "viagem_de_teste" / "images" / "foto1.jpg")
photo = Photo(filepath=photo_file_path_str, name="Foto 1", photo_hash="hash123", fk_travel_diary_id=diary.id)
session.add(photo)
session.commit()
photo_file_path = Path(photo_file_path_str)
photo_file_path.parent.mkdir(parents=True)
photo_file_path.touch()
yield {
"session": session,
"db_path": fake_db_path,
"config_dir": fake_config_dir,
"diaries_root": fake_diaries_root,
}
session.close()
@pytest.fixture
def entry_with_photo_references(session_with_one_diary):
session, diary = session_with_one_diary
photo1 = Photo(filepath="p1.jpg", name="P1", photo_hash="aaaaaaaa", fk_travel_diary_id=diary.id)
photo2 = Photo(filepath="p2.jpg", name="P2", photo_hash="bbbbbbbb", fk_travel_diary_id=diary.id)
session.add_all([photo1, photo2])
session.flush()
entry = Entry(
title="Entrada com Fotos",
text="Texto com a foto A [[photo::aaaaaaaa]] e também a foto B [[photo::bbbbbbbb]].",
date=datetime.now(),
travel_diary_id=diary.id,
photos=[photo1, photo2]
)
session.add(entry)
session.commit()
session.refresh(entry)
return session, entry
@pytest.fixture
def session_with_multiple_entries(session_with_one_diary):
session, diary = session_with_one_diary
session.query(Entry).delete()
entry1 = Entry(title="Entrada 1", text="Texto 1", date=datetime.now(), travel_diary_id=diary.id)
entry2 = Entry(title="Entrada 2", text="Texto 2", date=datetime.now(), travel_diary_id=diary.id)
session.add_all([entry1, entry2])
session.commit()
return session, diary
+39
View File
@@ -0,0 +1,39 @@
import zipfile
from pathlib import Path
from unittest.mock import patch, MagicMock
from pilgrim.service.backup_service import BackupService
from pilgrim.utils.directory_manager import DirectoryManager
import pytest
@patch.object(DirectoryManager, 'get_diaries_root')
@patch.object(DirectoryManager, 'get_config_directory')
@patch.object(DirectoryManager, 'get_database_path')
def test_create_backup_success(mock_get_db_path, mock_get_config_dir, mock_get_diaries_root, backup_test_env_files_only):
env = backup_test_env_files_only
session = env["session"]
mock_get_db_path.return_value = env["db_path"]
mock_get_config_dir.return_value = env["config_dir"]
mock_get_diaries_root.return_value = env["diaries_root"]
service = BackupService(session)
backup_zip_path = env["config_dir"] / "backup.zip"
success, returned_path = service.create_backup()
assert success is True
assert returned_path == backup_zip_path
assert backup_zip_path.exists()
with zipfile.ZipFile(backup_zip_path, 'r') as zf:
file_list = zf.namelist()
assert "database.sql" in file_list
assert "diaries/viagem_de_teste/images/foto1.jpg" in file_list
sql_dump = zf.read("database.sql").decode('utf-8')
assert "Viagem de Teste" in sql_dump
@patch.object(DirectoryManager, 'get_database_path')
def test_create_backup_fails_if_db_not_found(mock_get_db_path, tmp_path: Path):
non_existent_db_path = tmp_path / "non_existent.db"
mock_get_db_path.return_value = non_existent_db_path
mock_session = MagicMock()
service = BackupService(mock_session)
with pytest.raises(FileNotFoundError, match="No Database Found"):
service.create_backup()
+43
View File
@@ -261,3 +261,46 @@ def test_delete_returns_none_if_entry_does_not_exist(db_session):
non_existent_entry.id = 999
result = service.delete(non_existent_entry)
assert result is None
def test_delete_references_for_specific_photo(entry_with_photo_references):
session, entry = entry_with_photo_references
service = EntryService(session)
updated_entry = service.delete_references_for_specific_photo(entry, "aaaaaaaa")
assert "[[photo::aaaaaaaa]]" not in updated_entry.text
assert "[[photo::bbbbbbbb]]" in updated_entry.text
def test_delete_specific_photo_reference_does_nothing_if_no_match(entry_with_photo_references):
session, entry = entry_with_photo_references
service = EntryService(session)
original_text = entry.text
updated_entry = service.delete_references_for_specific_photo(entry, "cccccccc")
assert updated_entry.text == original_text
def test_delete_all_photo_references_removes_all_refs(entry_with_photo_references):
session, entry = entry_with_photo_references
service = EntryService(session)
updated_entry = service.delete_all_photo_references(entry)
assert "[[photo::aaaaaaaa]]" not in updated_entry.text
assert "[[photo::bbbbbbbb]]" not in updated_entry.text
def test_delete_all_photo_references_uses_truncated_hash(session_with_one_diary):
session, diary = session_with_one_diary
service = EntryService(session)
long_hash_photo = Photo(
filepath="long.jpg", name="Long",
photo_hash="1234567890abcdef", # Hash com 16 caracteres
fk_travel_diary_id=diary.id
)
entry = Entry(
title="Teste de Hash Curto",
text="Referência com hash truncado [[photo::12345678]].", # Texto usa só os 8 primeiros
date=datetime.now(),
travel_diary_id=diary.id
)
entry.photos.append(long_hash_photo)
session.add_all([long_hash_photo, entry])
session.commit()
updated_entry = service.delete_all_photo_references(entry)
expected_text = "Referência com hash truncado ."
assert "[[photo::12345678]]" not in updated_entry.text
+35 -2
View File
@@ -1,9 +1,12 @@
from unittest.mock import patch, MagicMock
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
from pilgrim import TravelDiary
from pilgrim.models.photo import Photo
from pilgrim.models.travel_diary import TravelDiary
from pilgrim.models.entry import Entry
from pilgrim.service.travel_diary_service import TravelDiaryService
@patch.object(TravelDiaryService, '_ensure_diary_directory')
@@ -141,3 +144,33 @@ def test_sanitize_directory_name_handles_uniqueness(db_session):
third_sanitized_name = service._sanitize_directory_name("Viagem para a Praia")
assert third_sanitized_name == "viagem_para_a_praia_2"
def test_delete_all_entries_successfully(session_with_multiple_entries):
session, diary = session_with_multiple_entries
service = TravelDiaryService(session)
diary_id = 1
assert session.query(Entry).filter_by(fk_travel_diary_id=diary_id).count() == 2
result = service.delete_all_entries(diary)
assert result is True
assert session.query(Entry).filter_by(fk_travel_diary_id=diary_id).count() == 0
@patch.object(TravelDiaryService, '_ensure_diary_directory')
@patch('pathlib.Path.unlink')
@patch('pathlib.Path.exists', return_value=True)
@patch('pilgrim.utils.DirectoryManager.get_diaries_root', return_value=Path("/fake/diaries_root"))
def test_delete_all_photos_orchestration(
mock_ensure_dir, mock_unlink, mock_exists, mock_get_root, entry_with_photo_references
):
session, entry = entry_with_photo_references
diary_id = entry.fk_travel_diary_id
service = TravelDiaryService(session)
assert session.query(Photo).filter_by(fk_travel_diary_id=diary_id).count() == 2
assert "[[photo::" in entry.text
diary = session.get(TravelDiary, diary_id)
result = service.delete_all_photos(diary)
assert result is True
photos_after_delete = session.query(Photo).filter_by(fk_travel_diary_id=diary_id).all()
assert len(photos_after_delete) == 0
session.refresh(entry)
assert "[[photo::" not in entry.text
assert mock_unlink.call_count == 2