Compare commits

...

10 Commits

Author SHA1 Message Date
Gustavo Henrique Miranda d13750f725
Merge pull request #23 from gmbrax/fix/wrong-database-access
Fix/wrong database access
2025-07-06 03:28:27 -03:00
Gustavo Henrique Santos Souza de Miranda 4dd5126d41 Changed back to use the entry_service.py and not call the database directly from the ui. 2025-07-06 03:26:06 -03:00
Gustavo Henrique Miranda 0b9b3c8cf0
Merge pull request #22 from gmbrax/fix/binding-not-working
Merging fix/binding-not-working into development
2025-07-06 03:11:17 -03:00
Gustavo Henrique Miranda 42c5471ba7
Merge branch 'development' into fix/binding-not-working 2025-07-06 03:10:24 -03:00
Gustavo Henrique Santos Souza de Miranda 8a10fddb14 Refactored bindings to use `Binding` class for consistency across modal and screen components. 2025-07-06 03:04:18 -03:00
Gustavo Henrique Miranda ad9d6ae3b5
Merge pull request #20 from gmbrax/feat/photo-copying-system-dir
Merging feat/photo-copying-system-dir to staging
2025-07-06 01:37:30 -03:00
Gustavo Henrique Miranda 20c56e2c1b
Merge pull request #19 from gmbrax/feat/photo-copying-system-dir
Feat/photo copying system dir
2025-07-06 01:18:12 -03:00
Gustavo Henrique Santos Souza de Miranda 090bbeda1a Implemented directory management and improved TravelDiary and Photo handling logic.
- Added `DirectoryManager` utility for consistent directory operations.
- Introduced directory name sanitization and uniqueness enforcement for `TravelDiary`.
- Updated `TravelDiaryService` with enhanced creation, update, and deletion workflows, including filesystem management.
- Improved `PhotoService` to hash files, copy photos to specific diary directories, and manage updates safely.
- Refined error handling and cascaded changes across related entities (e.g., `Entry` relationships).
2025-07-06 01:07:25 -03:00
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
11 changed files with 326 additions and 114 deletions

View File

@ -1,47 +1,18 @@
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
from pilgrim.utils import DirectoryManager
class Application:
def __init__(self):
self.config_dir = self._ensure_config_directory()
self.config_dir = DirectoryManager.get_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,9 +1,9 @@
from typing import Any
from pilgrim.models.photo_in_entry import photo_entry_association
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime
from sqlalchemy.orm import relationship
from pilgrim.models.photo_in_entry import photo_entry_association
from ..database import Base
@ -17,7 +17,9 @@ class Entry(Base):
"Photo",
secondary=photo_entry_association,
back_populates="entries")
fk_travel_diary_id = Column(Integer, ForeignKey("travel_diaries.id"),nullable=False)
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):
super().__init__(**kw)
self.title = title

View File

@ -1,14 +1,26 @@
from typing import Any
from sqlalchemy import Column, String, Integer
from sqlalchemy import Column, Integer, String, UniqueConstraint
from sqlalchemy.orm import relationship
from ..database import Base
from .. import database
class TravelDiary(Base):
class TravelDiary(database.Base):
__tablename__ = "travel_diaries"
id = Column(Integer, primary_key=True)
name = Column(String)
name = Column(String, nullable=False)
directory_name = Column(String, nullable=False, unique=True)
entries = relationship("Entry", back_populates="travel_diary", cascade="all, delete-orphan")
def __init__(self, name: str, **kw: Any):
__table_args__ = (
UniqueConstraint('directory_name', name='uq_travel_diary_directory_name'),
)
def __init__(self, name: str, directory_name: str = None, **kw: Any):
super().__init__(**kw)
self.name = name
self.directory_name = directory_name # Será definido pelo service
def __repr__(self):
return f"<TravelDiary(id={self.id}, name='{self.name}', directory_name='{self.directory_name}')>"

View File

@ -1,26 +1,76 @@
import hashlib
import os
import shutil
from datetime import datetime
from pathlib import Path
from typing import List
from datetime import datetime
import hashlib
from pilgrim.models.photo import Photo
from pilgrim.models.travel_diary import TravelDiary
from pilgrim.utils import DirectoryManager
class PhotoService:
def __init__(self, session):
self.session = session
def _hash_file(self,filepath):
def _hash_file(self, filepath: Path) -> str:
"""Calculate hash of a file using SHA3-384."""
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 _ensure_images_directory(self, travel_diary: TravelDiary) -> Path:
"""
Ensures the images directory exists for the given diary.
Returns the path to the images directory.
"""
images_dir = DirectoryManager.get_diary_images_directory(travel_diary.directory_name)
if not images_dir.exists():
images_dir.mkdir(parents=True)
os.chmod(images_dir, 0o700) # Ensure correct permissions
return images_dir
def _copy_photo_to_diary(self, source_path: Path, travel_diary: TravelDiary) -> Path:
"""
Copies a photo to the diary's images directory.
Returns the path to the copied file.
"""
images_dir = self._ensure_images_directory(travel_diary)
# Get original filename and extension
original_name = Path(source_path).name
# Create destination path
dest_path = images_dir / original_name
# If file with same name exists, add a number
counter = 1
while dest_path.exists():
name_parts = original_name.rsplit('.', 1)
if len(name_parts) > 1:
dest_path = images_dir / f"{name_parts[0]}_{counter}.{name_parts[1]}"
else:
dest_path = images_dir / f"{original_name}_{counter}"
counter += 1
# Copy the file
shutil.copy2(source_path, dest_path)
os.chmod(dest_path, 0o600) # Read/write for owner only
return dest_path
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
# Copy photo to diary's images directory
copied_path = self._copy_photo_to_diary(filepath, travel_diary)
# Convert addition_date string to datetime if needed
if isinstance(addition_date, str):
@ -28,20 +78,24 @@ class PhotoService:
addition_date = datetime.strptime(addition_date, "%Y-%m-%d %H:%M:%S")
except ValueError:
addition_date = None
# Calculate hash from the copied file
photo_hash = self._hash_file(copied_path)
new_photo = Photo(
filepath=filepath,
filepath=str(copied_path), # Store the path to the copied file
name=name,
caption=caption,
fk_travel_diary_id=travel_diary_id,
addition_date=addition_date,
photo_hash=self._hash_file(filepath)
photo_hash=photo_hash
)
self.session.add(new_photo)
self.session.commit()
self.session.refresh(new_photo)
return new_photo
def read_by_id(self, photo_id:int) -> Photo:
return self.session.query(Photo).get(photo_id)
@ -51,15 +105,30 @@ class PhotoService:
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
# If filepath changed, need to copy new file
if str(photo_dst.filepath) != str(original.filepath):
travel_diary = self.session.query(TravelDiary).filter(
TravelDiary.id == original.fk_travel_diary_id).first()
if travel_diary:
# Copy new photo
new_path = self._copy_photo_to_diary(Path(photo_dst.filepath), travel_diary)
# Delete old photo if it exists in our images directory
old_path = Path(original.filepath)
if old_path.exists() and str(DirectoryManager.get_diaries_root()) in str(old_path):
old_path.unlink()
original.filepath = str(new_path)
# Update hash based on the new copied file
original.photo_hash = self._hash_file(new_path)
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 = []
original.entries = photo_dst.entries # Replace instead of extend
self.session.commit()
self.session.refresh(original)
return original
@ -78,8 +147,12 @@ class PhotoService:
id=excluded.id,
photo_hash=excluded.photo_hash,
entries=excluded.entries,
)
# Delete the physical file if it exists in our images directory
file_path = Path(excluded.filepath)
if file_path.exists() and str(DirectoryManager.get_diaries_root()) in str(file_path):
file_path.unlink()
self.session.delete(excluded)
self.session.commit()

View File

@ -1,30 +1,132 @@
import os
import re
import shutil
from pathlib import Path
from pilgrim.utils import DirectoryManager
from sqlalchemy.exc import IntegrityError
from ..models.travel_diary import TravelDiary
import asyncio
class TravelDiaryService:
def __init__(self,session):
def __init__(self, session):
self.session = session
async def async_create(self, name:str):
new_travel_diary = TravelDiary(name)
self.session.add(new_travel_diary)
self.session.commit()
self.session.refresh(new_travel_diary)
return new_travel_diary
def _sanitize_directory_name(self, name: str) -> str:
"""
Sanitizes a diary name for use as a directory name.
- Removes special characters
- Replaces spaces with underscores
- Ensures name is unique by adding a suffix if needed
"""
# Remove special characters and replace spaces
safe_name = re.sub(r'[^\w\s-]', '', name)
safe_name = safe_name.strip().replace(' ', '_').lower()
def read_by_id(self, travel_id:int):
return self.session.query(TravelDiary).get(travel_id)
# Ensure we have a valid name
if not safe_name:
safe_name = "unnamed_diary"
# Check if name is already used in database
base_name = safe_name
counter = 1
while self.session.query(TravelDiary).filter_by(directory_name=safe_name).first() is not None:
safe_name = f"{base_name}_{counter}"
counter += 1
return safe_name
def _get_diary_directory(self, diary: TravelDiary) -> Path:
"""Returns the directory path for a diary."""
return DirectoryManager.get_diary_directory(diary.directory_name)
def _get_diary_data_directory(self, diary: TravelDiary) -> Path:
"""Returns the data directory path for a diary."""
return DirectoryManager.get_diary_data_directory(diary.directory_name)
def _ensure_diary_directory(self, diary: TravelDiary) -> Path:
"""
Creates and returns the directory structure for a diary:
~/.pilgrim/diaries/{directory_name}/data/
"""
# Create diary directory
diary_dir = self._get_diary_directory(diary)
diary_dir.mkdir(exist_ok=True)
os.chmod(diary_dir, 0o700)
# Create data subdirectory
data_dir = self._get_diary_data_directory(diary)
data_dir.mkdir(exist_ok=True)
os.chmod(data_dir, 0o700)
return data_dir
def _cleanup_diary_directory(self, diary: TravelDiary):
"""Removes the diary directory and all its contents."""
diary_dir = self._get_diary_directory(diary)
if diary_dir.exists():
shutil.rmtree(diary_dir)
async def async_create(self, name: str):
# Generate safe directory name
directory_name = self._sanitize_directory_name(name)
# Create diary with directory name
new_travel_diary = TravelDiary(name=name, directory_name=directory_name)
try:
self.session.add(new_travel_diary)
self.session.commit()
self.session.refresh(new_travel_diary)
# Create directory structure for the new diary
self._ensure_diary_directory(new_travel_diary)
return new_travel_diary
except IntegrityError:
self.session.rollback()
raise ValueError(f"Could not create diary: directory name '{directory_name}' already exists")
def read_by_id(self, travel_id: int):
diary = self.session.query(TravelDiary).get(travel_id)
if diary:
# Ensure directory exists when reading
self._ensure_diary_directory(diary)
return diary
def read_all(self):
return self.session.query(TravelDiary).all()
diaries = self.session.query(TravelDiary).all()
# Ensure directories exist for all diaries
for diary in diaries:
self._ensure_diary_directory(diary)
return diaries
def update(self, travel_diary_id: int, name: str):
original = self.read_by_id(travel_diary_id)
if original is not None:
original.name = name
self.session.commit()
self.session.refresh(original)
return original
try:
# Generate new directory name
new_directory_name = self._sanitize_directory_name(name)
old_directory = self._get_diary_directory(original)
# Update diary
original.name = name
original.directory_name = new_directory_name
self.session.commit()
self.session.refresh(original)
# Rename directory if it exists
new_directory = self._get_diary_directory(original)
if old_directory.exists() and old_directory != new_directory:
old_directory.rename(new_directory)
return original
except IntegrityError:
self.session.rollback()
raise ValueError(f"Could not update diary: directory name '{new_directory_name}' already exists")
return None
async def async_update(self, travel_diary_id: int, name: str):
return self.update(travel_diary_id, name)
@ -32,7 +134,14 @@ class TravelDiaryService:
def delete(self, travel_diary_id: TravelDiary):
excluded = self.read_by_id(travel_diary_id.id)
if excluded is not None:
self.session.delete(travel_diary_id)
self.session.commit()
return excluded
try:
# First delete the directory
self._cleanup_diary_directory(excluded)
# Then delete from database
self.session.delete(travel_diary_id)
self.session.commit()
return excluded
except Exception as e:
self.session.rollback()
raise ValueError(f"Could not delete diary: {str(e)}")
return None

View File

@ -1,4 +1,5 @@
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Vertical, Horizontal
from textual.screen import ModalScreen
from textual.widgets import Label, Input, Button
@ -6,7 +7,7 @@ from textual.widgets import Label, Input, Button
class EditDiaryModal(ModalScreen[tuple[int,str]]):
BINDINGS = [
("escape", "cancel", "Cancel"),
Binding("escape", "cancel", "Cancel"),
]
def __init__(self, diary_id: int):

View File

@ -1,40 +1,36 @@
from typing import Optional, List
import asyncio
import re
from datetime import datetime
from pathlib import Path
import hashlib
import re
import time
from textual.app import ComposeResult
from textual.screen import Screen
from textual.widgets import Header, Footer, Static, TextArea, OptionList, Input, Button
from textual.binding import Binding
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
from typing import Optional, List
from pilgrim.models.entry import Entry
from pilgrim.models.travel_diary import TravelDiary
from pilgrim.models.photo import Photo
from pilgrim.models.travel_diary import TravelDiary
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.edit_photo_modal import EditPhotoModal
from pilgrim.ui.screens.modals.file_picker_modal import FilePickerModal
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
class EditEntryScreen(Screen):
TITLE = "Pilgrim - Edit"
BINDINGS = [
("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"),
Binding("ctrl+q", "quit", "Quit"),
Binding("ctrl+s", "save", "Save"),
Binding("ctrl+n", "new_entry", "New Entry"),
Binding("ctrl+shift+n", "next_entry", "Next Entry"),
Binding("ctrl+shift+p", "prev_entry", "Previous Entry"),
Binding("ctrl+r", "rename_entry", "Rename Entry"),
Binding("f8", "toggle_sidebar", "Toggle Photos"),
Binding("f9", "toggle_focus", "Toggle Focus"),
Binding("escape", "back_to_list", "Back to List"),
]
def __init__(self, diary_id: int = 1):
@ -333,9 +329,11 @@ class EditEntryScreen(Screen):
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):
@ -913,13 +911,11 @@ class EditEntryScreen(Screen):
self.call_later(self._async_update_entry, content, photos_to_link)
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()
"""Creates a new entry and links the referenced photos."""
try:
service_manager = self.app.service_manager
entry_service = service_manager.get_entry_service()
# 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,
@ -929,7 +925,6 @@ class EditEntryScreen(Screen):
)
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)
@ -940,47 +935,51 @@ class EditEntryScreen(Screen):
self.is_new_entry = False
self.has_unsaved_changes = False
self._original_content = new_entry.text # Pode ser o texto com hashes curtos
self._original_content = new_entry.text
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}' Successfully saved")
self.notify(f"Entry '{new_entry.title}' saved successfully!")
else:
self.notify("Error creating the Entry")
self.notify("Error creating entry")
except Exception as e:
self.notify(f"Error creating the entry: {str(e)}")
self.notify(f"Error creating 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
"""Updates an existing entry and its photo links."""
try:
if not self.entries:
self.notify("No Entry to update")
self.notify("No entry to update")
return
service_manager = self.app.service_manager
entry_service = service_manager.get_entry_service()
current_entry = self.entries[self.current_entry_index]
entry_result : Entry = Entry(
entry_result = Entry(
id=current_entry.id,
title=current_entry.title,
text=updated_content,
photos=photos_to_link,
date=current_entry.date,
travel_diary_id=self.diary_id
travel_diary_id=self.diary_id,
fk_travel_diary_id=self.diary_id
)
entry_service.update(current_entry, entry_result)
# 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")
result = entry_service.update(current_entry, entry_result)
if result:
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")
except Exception as e:
# Desfaz as mudanças em caso de erro
self.notify(f"❌ Error on updating the entry:: {str(e)}")
self.notify(f"Error updating entry: {str(e)}")
def on_key(self, event):

View File

@ -1,11 +1,13 @@
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Vertical, Horizontal
from textual.screen import ModalScreen
from textual.widgets import Label, Input, Button
class NewDiaryModal(ModalScreen[str]):
BINDINGS = [
("escape", "cancel", "Cancel"),
Binding("escape", "cancel", "Cancel"),
]
def __init__(self):
super().__init__()

View File

@ -1,4 +1,5 @@
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Vertical, Horizontal
from textual.screen import ModalScreen
from textual.widgets import Label, Input, Button
@ -8,7 +9,7 @@ class RenameEntryModal(ModalScreen[str]):
"""A modal screen to rename a diary entry."""
BINDINGS = [
("escape", "cancel", "Cancel"),
Binding("escape", "cancel", "Cancel"),
]
def __init__(self, current_name: str):

View File

@ -0,0 +1,3 @@
from .directory_manager import DirectoryManager
__all__ = ['DirectoryManager']

View File

@ -0,0 +1,39 @@
import os
from pathlib import Path
class DirectoryManager:
@staticmethod
def get_config_directory() -> Path:
"""
Get the ~/.pilgrim directory path.
Creates it if it doesn't exist.
"""
home = Path.home()
config_dir = home / ".pilgrim"
config_dir.mkdir(exist_ok=True)
os.chmod(config_dir, 0o700)
return config_dir
@staticmethod
def get_diaries_root() -> Path:
"""Returns the path to the diaries directory."""
diaries_dir = DirectoryManager.get_config_directory() / "diaries"
diaries_dir.mkdir(exist_ok=True)
os.chmod(diaries_dir, 0o700)
return diaries_dir
@staticmethod
def get_diary_directory(directory_name: str) -> Path:
"""Returns the directory path for a specific diary."""
return DirectoryManager.get_diaries_root() / directory_name
@staticmethod
def get_diary_data_directory(directory_name: str) -> Path:
"""Returns the data directory path for a specific diary."""
return DirectoryManager.get_diary_directory(directory_name) / "data"
@staticmethod
def get_diary_images_directory(directory_name: str) -> Path:
"""Returns the images directory path for a specific diary."""
return DirectoryManager.get_diary_data_directory(directory_name) / "images"