Merge pull request #19 from gmbrax/feat/photo-copying-system-dir

Feat/photo copying system dir
This commit is contained in:
Gustavo Henrique Miranda 2025-07-06 01:18:12 -03:00 committed by GitHub
commit 20c56e2c1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 284 additions and 78 deletions

View File

@ -1,47 +1,18 @@
from pilgrim.database import Database from pilgrim.database import Database
from pilgrim.service.servicemanager import ServiceManager from pilgrim.service.servicemanager import ServiceManager
from pilgrim.ui.ui import UIApp from pilgrim.ui.ui import UIApp
from pathlib import Path from pilgrim.utils import DirectoryManager
import os
import sys
class Application: class Application:
def __init__(self): def __init__(self):
self.config_dir = self._ensure_config_directory() self.config_dir = DirectoryManager.get_config_directory()
self.database = Database() self.database = Database()
session = self.database.session() session = self.database.session()
session_manager = ServiceManager() session_manager = ServiceManager()
session_manager.set_session(session) session_manager.set_session(session)
self.ui = UIApp(session_manager) 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): def run(self):
self.database.create() self.database.create()
self.ui.run() self.ui.run()

View File

@ -1,9 +1,9 @@
from typing import Any from typing import Any
from pilgrim.models.photo_in_entry import photo_entry_association
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime from sqlalchemy import Column, Integer, String, ForeignKey, DateTime
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from pilgrim.models.photo_in_entry import photo_entry_association
from ..database import Base from ..database import Base
@ -17,7 +17,9 @@ class Entry(Base):
"Photo", "Photo",
secondary=photo_entry_association, secondary=photo_entry_association,
back_populates="entries") 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): def __init__(self, title: str, text: str, date: str, travel_diary_id: int, **kw: Any):
super().__init__(**kw) super().__init__(**kw)
self.title = title self.title = title

View File

@ -1,14 +1,26 @@
from typing import Any 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" __tablename__ = "travel_diaries"
id = Column(Integer, primary_key=True) 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) super().__init__(**kw)
self.name = name 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 pathlib import Path
from typing import List from typing import List
from datetime import datetime
import hashlib
from pilgrim.models.photo import Photo from pilgrim.models.photo import Photo
from pilgrim.models.travel_diary import TravelDiary from pilgrim.models.travel_diary import TravelDiary
from pilgrim.utils import DirectoryManager
class PhotoService: class PhotoService:
def __init__(self, session): def __init__(self, session):
self.session = 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') hash_func = hashlib.new('sha3_384')
with open(filepath, 'rb') as f: with open(filepath, 'rb') as f:
while chunk := f.read(8192): while chunk := f.read(8192):
hash_func.update(chunk) hash_func.update(chunk)
return hash_func.hexdigest() 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: def create(self, filepath: Path, name: str, travel_diary_id: int, caption=None, addition_date=None) -> Photo | None:
travel_diary = self.session.query(TravelDiary).filter(TravelDiary.id == travel_diary_id).first() travel_diary = self.session.query(TravelDiary).filter(TravelDiary.id == travel_diary_id).first()
if not travel_diary: if not travel_diary:
return None return None
# 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 # Convert addition_date string to datetime if needed
if isinstance(addition_date, str): if isinstance(addition_date, str):
@ -28,20 +78,24 @@ class PhotoService:
addition_date = datetime.strptime(addition_date, "%Y-%m-%d %H:%M:%S") addition_date = datetime.strptime(addition_date, "%Y-%m-%d %H:%M:%S")
except ValueError: except ValueError:
addition_date = None addition_date = None
# Calculate hash from the copied file
photo_hash = self._hash_file(copied_path)
new_photo = Photo( new_photo = Photo(
filepath=filepath, filepath=str(copied_path), # Store the path to the copied file
name=name, name=name,
caption=caption, caption=caption,
fk_travel_diary_id=travel_diary_id, fk_travel_diary_id=travel_diary_id,
addition_date=addition_date, addition_date=addition_date,
photo_hash=self._hash_file(filepath) photo_hash=photo_hash
) )
self.session.add(new_photo) self.session.add(new_photo)
self.session.commit() self.session.commit()
self.session.refresh(new_photo) self.session.refresh(new_photo)
return new_photo return new_photo
def read_by_id(self, photo_id:int) -> Photo: def read_by_id(self, photo_id:int) -> Photo:
return self.session.query(Photo).get(photo_id) 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: def update(self, photo_src: Photo, photo_dst: Photo) -> Photo | None:
original: Photo = self.read_by_id(photo_src.id) original: Photo = self.read_by_id(photo_src.id)
if original: if original:
original.filepath = photo_dst.filepath # 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.name = photo_dst.name
original.addition_date = photo_dst.addition_date original.addition_date = photo_dst.addition_date
original.caption = photo_dst.caption original.caption = photo_dst.caption
original.photo_hash = original.photo_hash
if photo_dst.entries and len(photo_dst.entries) > 0: if photo_dst.entries and len(photo_dst.entries) > 0:
if original.entries is None: if original.entries is None:
original.entries = [] original.entries = []
original.entries = photo_dst.entries # Replace instead of extend original.entries = photo_dst.entries # Replace instead of extend
self.session.commit() self.session.commit()
self.session.refresh(original) self.session.refresh(original)
return original return original
@ -78,8 +147,12 @@ class PhotoService:
id=excluded.id, id=excluded.id,
photo_hash=excluded.photo_hash, photo_hash=excluded.photo_hash,
entries=excluded.entries, 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.delete(excluded)
self.session.commit() 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 from ..models.travel_diary import TravelDiary
import asyncio
class TravelDiaryService: class TravelDiaryService:
def __init__(self,session): def __init__(self, session):
self.session = 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): # Ensure we have a valid name
return self.session.query(TravelDiary).get(travel_id) 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): 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): def update(self, travel_diary_id: int, name: str):
original = self.read_by_id(travel_diary_id) original = self.read_by_id(travel_diary_id)
if original is not None: if original is not None:
original.name = name try:
self.session.commit() # Generate new directory name
self.session.refresh(original) new_directory_name = self._sanitize_directory_name(name)
return original 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): async def async_update(self, travel_diary_id: int, name: str):
return self.update(travel_diary_id, name) return self.update(travel_diary_id, name)
@ -32,7 +134,14 @@ class TravelDiaryService:
def delete(self, travel_diary_id: TravelDiary): def delete(self, travel_diary_id: TravelDiary):
excluded = self.read_by_id(travel_diary_id.id) excluded = self.read_by_id(travel_diary_id.id)
if excluded is not None: if excluded is not None:
self.session.delete(travel_diary_id) try:
self.session.commit() # First delete the directory
return excluded 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 return None

View File

@ -1,25 +1,20 @@
from typing import Optional, List import re
import asyncio
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
import hashlib from typing import Optional, List
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 pilgrim.models.entry import Entry from pilgrim.models.entry import Entry
from pilgrim.models.travel_diary import TravelDiary
from pilgrim.models.photo import Photo 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.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.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.modals.file_picker_modal import FilePickerModal
from pilgrim.ui.screens.rename_entry_modal import RenameEntryModal 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): class EditEntryScreen(Screen):
@ -333,9 +328,11 @@ class EditEntryScreen(Screen):
all_photos = photo_service.read_all() 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 = [photo for photo in all_photos if photo.fk_travel_diary_id == diary_id]
self.cached_photos.sort(key=lambda x: x.id) self.cached_photos.sort(key=lambda x: x.id)
return self.cached_photos
except Exception as e: except Exception as e:
self.notify(f"Error loading photos: {str(e)}") self.notify(f"Error loading photos: {str(e)}")
return []
def action_toggle_sidebar(self): def action_toggle_sidebar(self):

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"