diff --git a/src/pilgrim/application.py b/src/pilgrim/application.py index 81973f8..380c4c2 100644 --- a/src/pilgrim/application.py +++ b/src/pilgrim/application.py @@ -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() diff --git a/src/pilgrim/models/entry.py b/src/pilgrim/models/entry.py index 374c82a..dac87b0 100644 --- a/src/pilgrim/models/entry.py +++ b/src/pilgrim/models/entry.py @@ -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 diff --git a/src/pilgrim/models/travel_diary.py b/src/pilgrim/models/travel_diary.py index 676b49b..1164bc4 100644 --- a/src/pilgrim/models/travel_diary.py +++ b/src/pilgrim/models/travel_diary.py @@ -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"" diff --git a/src/pilgrim/service/photo_service.py b/src/pilgrim/service/photo_service.py index ed1b2e3..4907dfb 100644 --- a/src/pilgrim/service/photo_service.py +++ b/src/pilgrim/service/photo_service.py @@ -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() diff --git a/src/pilgrim/service/travel_diary_service.py b/src/pilgrim/service/travel_diary_service.py index 7a355ca..34136e7 100644 --- a/src/pilgrim/service/travel_diary_service.py +++ b/src/pilgrim/service/travel_diary_service.py @@ -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 diff --git a/src/pilgrim/ui/screens/edit_entry_screen.py b/src/pilgrim/ui/screens/edit_entry_screen.py index 0a1d13d..d15f2ae 100644 --- a/src/pilgrim/ui/screens/edit_entry_screen.py +++ b/src/pilgrim/ui/screens/edit_entry_screen.py @@ -1,25 +1,20 @@ -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.containers import Container, Horizontal, Vertical +from textual.screen import Screen +from textual.widgets import Header, Footer, Static, TextArea, OptionList class EditEntryScreen(Screen): @@ -333,9 +328,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): diff --git a/src/pilgrim/utils/__init__.py b/src/pilgrim/utils/__init__.py new file mode 100644 index 0000000..f419fe1 --- /dev/null +++ b/src/pilgrim/utils/__init__.py @@ -0,0 +1,3 @@ +from .directory_manager import DirectoryManager + +__all__ = ['DirectoryManager'] diff --git a/src/pilgrim/utils/directory_manager.py b/src/pilgrim/utils/directory_manager.py new file mode 100644 index 0000000..c6a0708 --- /dev/null +++ b/src/pilgrim/utils/directory_manager.py @@ -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"