diff --git a/.gitignore b/.gitignore
index 08fdd16..629a919 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,146 @@
+# Database files
database.db
-__pycache__
+
+.build-vend/
+dist_nuitka/
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+build/
+temp/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+.python-version
+
+# pipenv
+Pipfile.lock
+
+# poetry
+poetry.lock
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.env.*
+.venv
+venv/
+ENV/
+env/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# IDE settings
+.vscode/
+.idea/
diff --git a/.idea/Pilgrim.iml b/.idea/Pilgrim.iml
deleted file mode 100644
index 3ce9a18..0000000
--- a/.idea/Pilgrim.iml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
deleted file mode 100644
index 105ce2d..0000000
--- a/.idea/inspectionProfiles/profiles_settings.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index daedced..0000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 94a25f7..0000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
deleted file mode 100644
index a012a27..0000000
--- a/.idea/workspace.xml
+++ /dev/null
@@ -1,201 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- "lastFilter": {
- "state": "OPEN",
- "assignee": "gmbrax"
- }
-}
- {
- "selectedUrlAndAccountId": {
- "url": "https://github.com/gmbrax/Pilgrim.git",
- "accountId": "213d8456-c67d-4cfd-99a6-337d47c35b4a"
- }
-}
- {
- "associatedIndex": 0
-}
-
-
-
-
-
- {
- "keyToString": {
- "ModuleVcsDetector.initialDetectionPerformed": "true",
- "Python.Database.executor": "Run",
- "Python.command.executor": "Run",
- "Python.main.executor": "Run",
- "Python.pilgrim.executor": "Run",
- "RunOnceActivity.ShowReadmeOnStart": "true",
- "RunOnceActivity.git.unshallow": "true",
- "git-widget-placeholder": "master",
- "node.js.detected.package.eslint": "true",
- "node.js.detected.package.tslint": "true",
- "node.js.selected.package.eslint": "(autodetect)",
- "node.js.selected.package.tslint": "(autodetect)",
- "nodejs_package_manager_path": "npm",
- "vue.rearranger.settings.migration": "true"
- }
-}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 1748985568579
-
-
- 1748985568579
-
-
-
-
-
- 1749004109515
-
-
-
- 1749004109515
-
-
-
- 1749006784623
-
-
-
- 1749006784623
-
-
-
- 1749140898576
-
-
-
- 1749140898576
-
-
-
- 1749155713848
-
-
-
- 1749155713848
-
-
-
- 1749164385581
-
-
-
- 1749164385581
-
-
-
- 1749168650225
-
-
-
- 1749168650225
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/README.md b/README.md
index d9baacb..7d8d17a 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,50 @@
# Python_Pilgrim
-Python Based Travel Diary
\ No newline at end of file
+## Overview
+
+**Python_Pilgrim** is a Python-based travel diary application designed to help users document and manage their travel experiences. The project provides tools for recording trips, organizing travel notes, and storing memories in a structured and accessible format.
+
+## Features
+
+- Create and manage travel diaries
+- Add, edit, and delete travel entries
+- Organize trips by date, location, or theme
+- Store photos, notes, and other media
+- Export and share travel logs
+
+## Requirements
+- Python 3.8 or higher
+- Linux operating system (tested on Ubuntu 20.04+)
+- Visual Studio Code (VSCode) for development (optional but strongly recommended)
+- pip (Python package installer)
+- Optional: virtualenv for isolated environments
+
+## Installation
+
+1. Clone the repository:
+ ```bash
+ git clone https://github.com/gmbrax/Pilgrim.git
+ ```
+2. Navigate to the project directory:
+ ```bash
+ cd Pilgrim
+ ```
+3. Create a virtual environment and, then, activate it:
+ ```bash
+ python -m venv .venv
+ source .venv/bin/activate
+ ```
+4. Install the required dependencies:
+ ```bash
+ pip install -r requirements.txt
+ ```
+
+## Usage
+
+To run the main application, execute:
+
+```bash
+python ??>.py
+```
+
+This will start the Python_Pilgrim application. Follow the on-screen instructions to create and manage your travel diaries.
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 805875b..2ade3e3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,7 +18,9 @@ classifiers = [
"Operating System :: OS Independent",
]
dependencies = [
- "sqlalchemy"
+ "sqlalchemy",
+ "textual",
+ "textual-dev"
]
[template.plugins.default]
src-layout = true
diff --git a/requirements.txt b/requirements.txt
index 10dc89b..015a545 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,3 +2,5 @@ greenlet==3.2.3
SQLAlchemy==2.0.41
typing_extensions==4.14.0
+
+textual~=3.3.0
diff --git a/src/pilgrim/application.py b/src/pilgrim/application.py
index 61d75ba..380c4c2 100644
--- a/src/pilgrim/application.py
+++ b/src/pilgrim/application.py
@@ -1,13 +1,21 @@
from pilgrim.database import Database
from pilgrim.service.servicemanager import ServiceManager
+from pilgrim.ui.ui import UIApp
+from pilgrim.utils import DirectoryManager
class Application:
def __init__(self):
+ 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 run(self):
self.database.create()
+ self.ui.run()
def get_service_manager(self):
session = self.database.session()
diff --git a/src/pilgrim/command.py b/src/pilgrim/command.py
index 1af56cc..39dd127 100644
--- a/src/pilgrim/command.py
+++ b/src/pilgrim/command.py
@@ -4,3 +4,6 @@ from pilgrim.application import Application
def main():
app = Application()
app.run()
+
+if __name__ == "__main__":
+ main()
diff --git a/src/pilgrim/database.py b/src/pilgrim/database.py
index 389b15b..aba4219 100644
--- a/src/pilgrim/database.py
+++ b/src/pilgrim/database.py
@@ -1,21 +1,52 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
+from pathlib import Path
+import os
+import shutil
Base = declarative_base()
+def get_database_path() -> Path:
+ """
+ Get the database file path following XDG Base Directory specification.
+ Creates the directory if it doesn't exist.
+ """
+ # Get home directory
+ home = Path.home()
+
+ # Create .pilgrim directory if it doesn't exist
+ pilgrim_dir = home / ".pilgrim"
+ pilgrim_dir.mkdir(exist_ok=True)
+
+ # Database file path
+ db_path = pilgrim_dir / "database.db"
+
+ # If database doesn't exist in new location but exists in current directory,
+ # migrate it
+ if not db_path.exists():
+ current_db = Path("database.db")
+ if current_db.exists():
+ shutil.copy2(current_db, db_path)
+ print(f"Database migrated from {current_db} to {db_path}")
+
+ return db_path
class Database:
def __init__(self):
+ db_path = get_database_path()
self.engine = create_engine(
- "sqlite:///database.db",
+ f"sqlite:///{db_path}",
echo=False,
connect_args={"check_same_thread": False},
)
- self.session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
+ self._session_maker = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
def create(self):
Base.metadata.create_all(self.engine)
+ def session(self):
+ return self._session_maker()
+
def get_db(self):
- return self.session()
+ return self._session_maker()
diff --git a/src/pilgrim/models/entry.py b/src/pilgrim/models/entry.py
index 4a1132c..dac87b0 100644
--- a/src/pilgrim/models/entry.py
+++ b/src/pilgrim/models/entry.py
@@ -1,9 +1,9 @@
from typing import Any
-from sqlalchemy import Column, Integer, String, ForeignKey
+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
@@ -12,12 +12,14 @@ class Entry(Base):
id = Column(Integer, primary_key=True)
title = Column(String)
text = Column(String)
- date = Column(String)
+ date = Column(DateTime)
photos = relationship(
"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/photo.py b/src/pilgrim/models/photo.py
index f6e0406..02fdcc0 100644
--- a/src/pilgrim/models/photo.py
+++ b/src/pilgrim/models/photo.py
@@ -1,6 +1,8 @@
from typing import Any
+from datetime import datetime
+from pathlib import Path
-from sqlalchemy import Column, Integer, String, ForeignKey
+from sqlalchemy import Column, Integer, String, ForeignKey, DateTime
from sqlalchemy.orm import relationship
from pilgrim.models.photo_in_entry import photo_entry_association
@@ -12,8 +14,9 @@ class Photo(Base):
id = Column(Integer, primary_key=True)
filepath = Column(String)
name = Column(String)
- addition_date = Column(String)
+ addition_date = Column(DateTime, default=datetime.now)
caption = Column(String)
+ photo_hash = Column(String,name='hash')
entries = relationship(
"Entry",
secondary=photo_entry_association,
@@ -22,10 +25,17 @@ class Photo(Base):
fk_travel_diary_id = Column(Integer, ForeignKey("travel_diaries.id"),nullable=False)
- def __init__(self, filepath, name, addition_date=None, caption=None, entries=None, **kw: Any):
+ def __init__(self, filepath, name, photo_hash, addition_date=None, caption=None, entries=None, fk_travel_diary_id=None, **kw: Any):
super().__init__(**kw)
- self.filepath = filepath
+ # Convert Path to string if needed
+ if isinstance(filepath, Path):
+ self.filepath = str(filepath)
+ else:
+ self.filepath = filepath
self.name = name
- self.addition_date = addition_date
+ self.addition_date = addition_date if addition_date is not None else datetime.now()
self.caption = caption
- self.entries = entries
+ self.photo_hash = photo_hash
+ self.entries = entries if entries is not None else []
+ if fk_travel_diary_id is not None:
+ self.fk_travel_diary_id = fk_travel_diary_id
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/entry_service.py b/src/pilgrim/service/entry_service.py
index f316f97..040f5a8 100644
--- a/src/pilgrim/service/entry_service.py
+++ b/src/pilgrim/service/entry_service.py
@@ -1,33 +1,41 @@
+from datetime import datetime
from typing import List
from ..models.entry import Entry
from ..models.travel_diary import TravelDiary
+from ..models.photo import Photo # ✨ Importe o modelo Photo
class EntryService:
- def __init__(self,session):
+ def __init__(self, session):
self.session = session
- def create(self, travel_diary_id:int, title: str, text: str, date: str, ):
+ # ✨ Modifique a assinatura para aceitar a lista de fotos
+ def create(self, travel_diary_id: int, title: str, text: str, date: datetime, photos: List[Photo]):
travel_diary = self.session.query(TravelDiary).filter(TravelDiary.id == travel_diary_id).first()
if not travel_diary:
return None
- new_entry = Entry(title,text,date,travel_diary_id)
+
+ new_entry = Entry(title, text, date, travel_diary_id,photos=photos)
+
+ # ✨ Atribua a relação ANTES de adicionar e fazer o commit
+ new_entry.photos = photos
+
self.session.add(new_entry)
self.session.commit()
self.session.refresh(new_entry)
return new_entry
- def read_by_id(self,entry_id:int)->Entry:
+ def read_by_id(self, entry_id: int) -> Entry:
entry = self.session.query(Entry).filter(Entry.id == entry_id).first()
return entry
- def read_all(self)-> List[Entry]:
+ def read_all(self) -> List[Entry]:
entries = self.session.query(Entry).all()
return entries
- def update(self,entry_src:Entry,entry_dst:Entry) -> Entry | None:
- original:Entry = self.read_by_id(entry_src.id)
+ def update(self, entry_src: Entry, entry_dst: Entry) -> Entry | None:
+ original: Entry = self.read_by_id(entry_src.id)
if original:
original.title = entry_dst.title
original.text = entry_dst.text
@@ -39,7 +47,7 @@ class EntryService:
return original
return None
- def delete(self,entry_src:Entry)-> Entry | None:
+ def delete(self, entry_src: Entry) -> Entry | None:
excluded = self.read_by_id(entry_src.id)
if excluded is not None:
self.session.delete(excluded)
diff --git a/src/pilgrim/service/mocks/__init__.py b/src/pilgrim/service/mocks/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/pilgrim/service/mocks/entry_service_mock.py b/src/pilgrim/service/mocks/entry_service_mock.py
new file mode 100644
index 0000000..b78f59e
--- /dev/null
+++ b/src/pilgrim/service/mocks/entry_service_mock.py
@@ -0,0 +1,123 @@
+from typing import List, Tuple
+import asyncio
+from pilgrim.service.entry_service import EntryService
+from pilgrim.models.entry import Entry
+
+
+class EntryServiceMock(EntryService):
+ def __init__(self):
+ super().__init__(None)
+
+ self.mock_data = {
+ 1: Entry(title="The Adventure Begins", text="I'm hopping in the Plane to finally visit canadian lands",
+ date="26/07/2025", travel_diary_id=1, id=1,
+ photos=[]),
+ 2: Entry(title="The Landing", text="Finally on Canadian Soil", date="27/07/2025",
+ travel_diary_id=1, id=2,photos=[]),
+ 3: Entry(title="The Mount Royal", text="The Mount Royal is fucking awesome", date="28/07/2025",
+ travel_diary_id=1, id=3, photos=[]),
+ 4: Entry(title="Old Montreal", text="Exploring the historic district", date="29/07/2025",
+ travel_diary_id=1, id=4, photos=[]),
+ 5: Entry(title="Notre-Dame Basilica", text="Beautiful architecture", date="30/07/2025",
+ travel_diary_id=1, id=5, photos=[]),
+ 6: Entry(title="Parc Jean-Drapeau", text="Great views of the city", date="31/07/2025",
+ travel_diary_id=1, id=6, photos=[]),
+ 7: Entry(title="La Ronde", text="Amusement park fun", date="01/08/2025",
+ travel_diary_id=1, id=7, photos=[]),
+ 8: Entry(title="Biodome", text="Nature and science", date="02/08/2025",
+ travel_diary_id=1, id=8, photos=[]),
+ 9: Entry(title="Botanical Gardens", text="Peaceful walk", date="03/08/2025",
+ travel_diary_id=1, id=9, photos=[]),
+ 10: Entry(title="Olympic Stadium", text="Historic venue", date="04/08/2025",
+ travel_diary_id=1, id=10, photos=[]),
+ }
+ self._next_id = 11
+
+ # Synchronous methods (kept for compatibility)
+ def create(self, travel_diary_id: int, title: str, text: str, date: str) -> Entry:
+ """Synchronous version"""
+ new_entry = Entry(title, text, date, travel_diary_id, id=self._next_id)
+ self.mock_data[self._next_id] = new_entry
+ self._next_id += 1
+ return new_entry
+
+ def read_by_id(self, entry_id: int) -> Entry | None:
+ """Synchronous version"""
+ return self.mock_data.get(entry_id)
+
+ def read_all(self) -> List[Entry]:
+ """Synchronous version"""
+ return list(self.mock_data.values())
+
+ def read_by_travel_diary_id(self, travel_diary_id: int) -> List[Entry]:
+ """Synchronous version - reads entries by diary"""
+ return [entry for entry in self.mock_data.values() if entry.fk_travel_diary_id == travel_diary_id]
+
+ def read_paginated(self, travel_diary_id: int, page: int = 1, page_size: int = 5) -> Tuple[List[Entry], int, int]:
+ """Synchronous version - reads paginated entries by diary"""
+ entries = self.read_by_travel_diary_id(travel_diary_id)
+ entries.sort(key=lambda x: x.id, reverse=True) # Most recent first
+
+ total_entries = len(entries)
+ total_pages = (total_entries + page_size - 1) // page_size
+
+ start_index = (page - 1) * page_size
+ end_index = start_index + page_size
+
+ page_entries = entries[start_index:end_index]
+
+ return page_entries, total_pages, total_entries
+
+ def update(self, entry_src: Entry, entry_dst: Entry) -> Entry | None:
+ """Synchronous version"""
+ item_to_update = self.mock_data.get(entry_src.id)
+ if item_to_update:
+ item_to_update.title = entry_dst.title if entry_dst.title is not None else item_to_update.title
+ item_to_update.text = entry_dst.text if entry_dst.text is not None else item_to_update.text
+ item_to_update.date = entry_dst.date if entry_dst.date is not None else item_to_update.date
+ item_to_update.fk_travel_diary_id = entry_dst.fk_travel_diary_id if (entry_dst.fk_travel_diary_id
+ is not None) else entry_dst.id
+ item_to_update.photos.extend(entry_dst.photos)
+
+ return item_to_update
+ return None
+
+ def delete(self, entry_src: Entry) -> Entry | None:
+ """Synchronous version"""
+ return self.mock_data.pop(entry_src.id, None)
+
+ # Async methods (main)
+ async def async_create(self, travel_diary_id: int, title: str, text: str, date: str) -> Entry:
+ """Async version"""
+ await asyncio.sleep(0.01) # Simulates I/O
+ return self.create(travel_diary_id, title, text, date)
+
+ async def async_read_by_id(self, entry_id: int) -> Entry | None:
+ """Async version"""
+ await asyncio.sleep(0.01) # Simulates I/O
+ return self.read_by_id(entry_id)
+
+ async def async_read_all(self) -> List[Entry]:
+ """Async version"""
+ await asyncio.sleep(0.01) # Simulates I/O
+ return self.read_all()
+
+ async def async_read_by_travel_diary_id(self, travel_diary_id: int) -> List[Entry]:
+ """Async version - reads entries by diary"""
+ await asyncio.sleep(0.01) # Simulates I/O
+ return self.read_by_travel_diary_id(travel_diary_id)
+
+ async def async_read_paginated(self, travel_diary_id: int, page: int = 1, page_size: int = 5) -> Tuple[List[Entry], int, int]:
+ """Async version - reads paginated entries by diary"""
+ await asyncio.sleep(0.01) # Simulates I/O
+ return self.read_paginated(travel_diary_id, page, page_size)
+
+ async def async_update(self, entry_src: Entry, entry_dst: Entry) -> Entry | None:
+ """Async version"""
+ await asyncio.sleep(0.01) # Simulates I/O
+ return self.update(entry_src, entry_dst)
+
+ async def async_delete(self, entry_src: Entry) -> Entry | None:
+ """Async version"""
+ await asyncio.sleep(0.01) # Simulates I/O
+ return self.delete(entry_src)
\ No newline at end of file
diff --git a/src/pilgrim/service/mocks/photo_service_mock.py b/src/pilgrim/service/mocks/photo_service_mock.py
new file mode 100644
index 0000000..0f9078a
--- /dev/null
+++ b/src/pilgrim/service/mocks/photo_service_mock.py
@@ -0,0 +1,48 @@
+from pathlib import Path
+from typing import List
+
+from pilgrim.models.photo import Photo
+from pilgrim.service.photo_service import PhotoService
+
+
+class PhotoServiceMock(PhotoService):
+ def __init__(self):
+ super().__init__(None)
+ self.mock_data = {}
+ self._next_id = 1
+
+ def create(self, filepath: Path, name: str, travel_diary_id, addition_date=None, caption=None) -> Photo | None:
+ new_photo = Photo(
+ filepath=filepath,
+ name=name,
+ addition_date=addition_date,
+ caption=caption,
+ fk_travel_diary_id=travel_diary_id
+ )
+ new_photo.id = self._next_id
+ self.mock_data[self._next_id] = new_photo
+ self._next_id += 1
+ return new_photo
+
+
+ def read_by_id(self, photo_id: int) -> Photo:
+ return self.mock_data.get(photo_id)
+
+ def read_all(self) -> List[Photo]:
+ return list(self.mock_data.values())
+
+ def update(self, photo_src: Photo, photo_dst: Photo) -> Photo | None:
+ item_to_update: Photo = self.mock_data.get(photo_src.id)
+ if item_to_update:
+ item_to_update.filepath = photo_dst.filepath if photo_dst.filepath else item_to_update.filepath
+ item_to_update.name = photo_dst.name if photo_dst.name else item_to_update.name
+ item_to_update.caption = photo_dst.caption if photo_dst.caption else item_to_update.caption
+ item_to_update.addition_date = photo_dst.addition_date if photo_dst.addition_date else item_to_update.addition_date
+ item_to_update.fk_travel_diary_id = photo_dst.fk_travel_diary_id if photo_dst.fk_travel_diary_id else item_to_update.fk_travel_diary_id
+ if photo_dst.entries:
+ item_to_update.entries = photo_dst.entries
+ return item_to_update
+ return None
+
+ def delete(self, photo_src: Photo) -> Photo | None:
+ return self.mock_data.pop(photo_src.id, None)
diff --git a/src/pilgrim/service/mocks/service_manager_mock.py b/src/pilgrim/service/mocks/service_manager_mock.py
new file mode 100644
index 0000000..2715171
--- /dev/null
+++ b/src/pilgrim/service/mocks/service_manager_mock.py
@@ -0,0 +1,23 @@
+from pilgrim.service.mocks.entry_service_mock import EntryServiceMock
+from pilgrim.service.mocks.photo_service_mock import PhotoServiceMock
+from pilgrim.service.mocks.travel_diary_service_mock import TravelDiaryServiceMock
+from pilgrim.service.photo_service import PhotoService
+from pilgrim.service.servicemanager import ServiceManager
+
+
+class ServiceManagerMock(ServiceManager):
+ def __init__(self):
+ super().__init__()
+ # Cria instâncias únicas para manter estado consistente
+ self._travel_diary_service = TravelDiaryServiceMock()
+ self._entry_service = EntryServiceMock()
+ self._photo_service = PhotoServiceMock()
+
+ def get_entry_service(self):
+ return self._entry_service
+
+ def get_travel_diary_service(self):
+ return self._travel_diary_service
+
+ def get_photo_service(self):
+ return self._photo_service
\ No newline at end of file
diff --git a/src/pilgrim/service/mocks/travel_diary_service_mock.py b/src/pilgrim/service/mocks/travel_diary_service_mock.py
new file mode 100644
index 0000000..c5b4c6e
--- /dev/null
+++ b/src/pilgrim/service/mocks/travel_diary_service_mock.py
@@ -0,0 +1,67 @@
+from pilgrim.service.travel_diary_service import TravelDiaryService
+from pilgrim.models.travel_diary import TravelDiary
+import asyncio
+
+
+class TravelDiaryServiceMock(TravelDiaryService):
+ def __init__(self):
+ super().__init__(None)
+ self.mock_data = {
+ 1: TravelDiary(id=1, name="Montreal"),
+ 2: TravelDiary(id=2, name="Rio de Janeiro"),
+ }
+ self._next_id = 3
+
+ # Synchronous methods (original)
+ def create(self, name: str):
+ """Synchronous version"""
+ new_travel_diary = TravelDiary(id=self._next_id, name=name)
+ self.mock_data[self._next_id] = new_travel_diary
+ self._next_id += 1
+ return new_travel_diary
+
+ def read_by_id(self, travel_id: int):
+ """Synchronous version"""
+ return self.mock_data.get(travel_id)
+
+ def read_all(self):
+ """Synchronous version"""
+ return list(self.mock_data.values())
+
+ def update(self, travel_diary_id: int, name: str):
+ """Synchronous version"""
+ item_to_update = self.mock_data.get(travel_diary_id)
+ if item_to_update:
+ item_to_update.name = name
+ return item_to_update
+ return None
+
+ def delete(self, travel_diary_id: int):
+ """Synchronous version"""
+ return self.mock_data.pop(travel_diary_id, None)
+
+ # Async methods (new)
+ async def async_create(self, name: str):
+ """Async version"""
+ await asyncio.sleep(0.01) # Simulates I/O
+ return self.create(name)
+
+ async def async_read_by_id(self, travel_id: int):
+ """Async version"""
+ await asyncio.sleep(0.01) # Simulates I/O
+ return self.read_by_id(travel_id)
+
+ async def async_read_all(self):
+ """Async version"""
+ await asyncio.sleep(0.01) # Simulates I/O
+ return self.read_all()
+
+ async def async_update(self, travel_diary_id: int, name: str):
+ """Async version"""
+ await asyncio.sleep(0.01) # Simulates I/O
+ return self.update(travel_diary_id, name)
+
+ async def async_delete(self, travel_diary_id: int):
+ """Async version"""
+ await asyncio.sleep(0.01) # Simulates I/O
+ return self.delete(travel_diary_id)
\ No newline at end of file
diff --git a/src/pilgrim/service/photo_service.py b/src/pilgrim/service/photo_service.py
index b1086a9..4907dfb 100644
--- a/src/pilgrim/service/photo_service.py
+++ b/src/pilgrim/service/photo_service.py
@@ -1,47 +1,161 @@
+import hashlib
+import os
+import shutil
+from datetime import datetime
from pathlib import Path
from typing import List
-from pilgrim import Photo, TravelDiary
+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 create(self, filepath:Path, name:str, travel_diary_id, addition_date=None, caption=None, ) -> Photo | None:
+ 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
- new_photo = Photo(filepath, name, addition_date=addition_date, caption=caption)
+
+ # 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):
+ try:
+ 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=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=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)
def read_all(self) -> List[Photo]:
return self.session.query(Photo).all()
- def update(self,photo_src:Photo,photo_dst:Photo) -> Photo | None:
- original:Photo = self.read_by_id(photo_src.id)
+ def update(self, photo_src: Photo, photo_dst: Photo) -> Photo | None:
+ original: Photo = self.read_by_id(photo_src.id)
if original:
- original.filepath = photo_dst.filepath
+ # 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.entries.extend(photo_dst.entries)
+
+ if photo_dst.entries and len(photo_dst.entries) > 0:
+ if original.entries is None:
+ original.entries = []
+ original.entries = photo_dst.entries # Replace instead of extend
+
self.session.commit()
self.session.refresh(original)
return original
return None
- def delete(self, photo_src:Photo) -> Photo | None:
+ def delete(self, photo_src: Photo) -> Photo | None:
excluded = self.read_by_id(photo_src.id)
if excluded:
+ # Store photo data before deletion
+ deleted_photo = Photo(
+ filepath=excluded.filepath,
+ name=excluded.name,
+ addition_date=excluded.addition_date,
+ caption=excluded.caption,
+ fk_travel_diary_id=excluded.fk_travel_diary_id,
+ id=excluded.id,
+ 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()
- self.session.refresh(excluded)
- return excluded
+
+ return deleted_photo
return None
diff --git a/src/pilgrim/service/servicemanager.py b/src/pilgrim/service/servicemanager.py
index b0b0cde..b3ef9d0 100644
--- a/src/pilgrim/service/servicemanager.py
+++ b/src/pilgrim/service/servicemanager.py
@@ -1,4 +1,5 @@
from pilgrim.service.entry_service import EntryService
+from pilgrim.service.photo_service import PhotoService
from pilgrim.service.travel_diary_service import TravelDiaryService
@@ -17,3 +18,7 @@ class ServiceManager:
if self.session is not None:
return TravelDiaryService(self.session)
return None
+ def get_photo_service(self):
+ if self.session is not None:
+ return PhotoService(self.session)
+ return None
\ No newline at end of file
diff --git a/src/pilgrim/service/travel_diary_service.py b/src/pilgrim/service/travel_diary_service.py
index 0be4cc0..34136e7 100644
--- a/src/pilgrim/service/travel_diary_service.py
+++ b/src/pilgrim/service/travel_diary_service.py
@@ -1,35 +1,147 @@
+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
class TravelDiaryService:
- def __init__(self,session):
+ def __init__(self, session):
self.session = session
- def 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_src:TravelDiary,travel_diary_dst:TravelDiary):
- original = self.read_by_id(travel_diary_src.id)
+ def update(self, travel_diary_id: int, name: str):
+ original = self.read_by_id(travel_diary_id)
if original is not None:
- original.name = travel_diary_dst.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")
- def delete(self, travel_diary_src:TravelDiary):
- excluded = self.read_by_id(travel_diary_src.id)
- if excluded is not None:
- self.session.delete(travel_diary_src)
- self.session.commit()
- return excluded
+ return None
+
+ async def async_update(self, travel_diary_id: int, name: str):
+ return self.update(travel_diary_id, name)
+
+ def delete(self, travel_diary_id: TravelDiary):
+ excluded = self.read_by_id(travel_diary_id.id)
+ if excluded is not None:
+ 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/__init__.py b/src/pilgrim/ui/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/pilgrim/ui/screens/__init__.py b/src/pilgrim/ui/screens/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/pilgrim/ui/screens/about_screen.py b/src/pilgrim/ui/screens/about_screen.py
new file mode 100644
index 0000000..bedb479
--- /dev/null
+++ b/src/pilgrim/ui/screens/about_screen.py
@@ -0,0 +1,73 @@
+from textual.app import ComposeResult
+from textual.binding import Binding
+from textual.screen import Screen
+from textual.widgets import Header, Footer, Button, Label, TextArea
+from textual.containers import Container
+
+class AboutScreen(Screen[bool]):
+ """Screen to display application information."""
+
+ TITLE = "Pilgrim - About"
+
+ BINDINGS = [
+ Binding("escape", "dismiss", "Close"),
+ ]
+
+ def __init__(self):
+ super().__init__()
+ self.header = Header()
+ self.footer = Footer()
+ self.app_title = Label("Pilgrim", id="AboutScreen_AboutTitle",classes="AboutScreen_AboutTitle")
+ self.content = Label("A TUI Based Travel Diary Application", id="AboutScreen_AboutContent",
+ classes="AboutScreen_AboutContent")
+ self.version = Label("Version: 0.0.1", id="AboutScreen_AboutVersion",
+ classes="AboutScreen_AboutVersion")
+ self.developer = Label("Developed By: Gustavo Henrique Miranda ", id="AboutScreen_AboutAuthor")
+ self.contact = Label("git.gustavomiranda.xyz", id="AboutScreen_AboutContact",
+ classes="AboutScreen_AboutContact")
+ self.license = TextArea(id="AboutScreen_AboutLicense",
+ classes="AboutScreen_AboutLicense")
+ self.license.text = """Copyright (c) 2025 GHMiranda.
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE."""
+ self.license.read_only = True
+ self.about_container = Container(self.app_title, self.content, self.version, self.developer,
+ self.contact, id="AboutScreen_SubContainer",
+ classes="AboutScreen_SubContainer")
+ self.container = Container(self.about_container, self.license, id="AboutScreen_AboutContainer",
+ classes="AboutScreen_AboutContainer")
+
+ def compose(self) -> ComposeResult:
+ yield self.header
+ yield self.container
+ yield self.footer
+
+ def on_button_pressed(self, event: Button.Pressed) -> None:
+ """Handles button clicks."""
+ if "about-close-button" in event.button.classes:
+ self.dismiss(False)
+ elif "about-info-button" in event.button.classes:
+ self.notify("More information would be displayed here!", title="Info")
+
+ def action_dismiss(self, **kwargs) -> None:
+ """Closes the about box using dismiss.
+ :param **kwargs:
+ """
+ self.dismiss(False)
+
+ def on_key(self, event) -> None:
+ """Intercepts specific keys."""
+ if event.key == "escape":
+ self.dismiss(False)
+ event.prevent_default()
+ elif event.key == "enter":
+ self.dismiss(False)
+ event.prevent_default()
\ No newline at end of file
diff --git a/src/pilgrim/ui/screens/diary_list_screen.py b/src/pilgrim/ui/screens/diary_list_screen.py
new file mode 100644
index 0000000..066fbae
--- /dev/null
+++ b/src/pilgrim/ui/screens/diary_list_screen.py
@@ -0,0 +1,299 @@
+from typing import Optional, Tuple
+import asyncio
+
+from textual.app import ComposeResult
+from textual.screen import Screen
+from textual.widgets import Header, Footer, Label, Static, OptionList, Button
+from textual.binding import Binding
+from textual.containers import Vertical, Container, Horizontal
+
+from pilgrim.models.travel_diary import TravelDiary
+from pilgrim.ui.screens.about_screen import AboutScreen
+from pilgrim.ui.screens.edit_diary_modal import EditDiaryModal
+from pilgrim.ui.screens.new_diary_modal import NewDiaryModal
+from pilgrim.ui.screens.edit_entry_screen import EditEntryScreen
+
+
+class DiaryListScreen(Screen):
+ TITLE = "Pilgrim - Main"
+
+ BINDINGS = [
+ Binding("n", "new_diary", "New diary"),
+ Binding("^q", "quit", "Quit Pilgrim"),
+ Binding("enter", "open_selected_diary", "Open diary"),
+ Binding("e", "edit_selected_diary", "Edit diary"),
+ Binding("r", "force_refresh", "Force refresh"),
+ ]
+
+ def __init__(self):
+ super().__init__()
+ self.selected_diary_index = None
+ self.diary_id_map = {}
+ self.is_refreshing = False
+
+ self.header = Header()
+ self.footer = Footer()
+ self.diary_list = OptionList(classes="DiaryListScreen-DiaryListOptions")
+ self.new_diary_button = Button("New diary", id="new_diary", classes="DiaryListScreen-NewDiaryButton")
+ self.edit_diary_button = Button("Edit diary", id="edit_diary", classes="DiaryListScreen-EditDiaryButton")
+ self.open_diary = Button("Open diary", id="open_diary", classes="DiaryListScreen-OpenDiaryButton")
+ self.buttons_grid = Horizontal(
+ self.new_diary_button, self.edit_diary_button, self.open_diary,
+ classes="DiaryListScreen-ButtonsGrid"
+ )
+ self.tips = Static(
+ "Tip: use ↑↓ to navigate • ENTER to Select • "
+ "TAB to alternate the fields • SHIFT + TAB to alternate back • "
+ "Ctrl+P for command palette • R to force refresh",
+ classes="DiaryListScreen-DiaryListTips"
+ )
+ self.container = Container(
+ self.diary_list, self.buttons_grid, self.tips,
+ classes="DiaryListScreen-DiaryListContainer"
+ )
+
+ def compose(self) -> ComposeResult:
+ yield self.header
+ yield self.container
+ yield self.footer
+
+ def on_mount(self) -> None:
+ # Uses synchronous version for initial mount
+ self.refresh_diaries()
+ self.update_buttons_state()
+
+ def refresh_diaries(self):
+ """Synchronous version of refresh"""
+ try:
+ service_manager = self.app.service_manager
+ travel_diary_service = service_manager.get_travel_diary_service()
+
+ # Uses synchronous method
+ diaries = travel_diary_service.read_all()
+
+ # Saves current state
+ current_diary_id = None
+ if (self.selected_diary_index is not None and
+ self.selected_diary_index in self.diary_id_map):
+ current_diary_id = self.diary_id_map[self.selected_diary_index]
+
+ # Clears and rebuilds
+ self.diary_list.clear_options()
+ self.diary_id_map = {}
+
+ if not diaries:
+ self.diary_list.add_option("[dim]No diaries found. Press 'N' to create a new one![/dim]")
+ self.selected_diary_index = None
+ else:
+ new_selected_index = 0
+
+ for index, diary in enumerate(diaries):
+ self.diary_id_map[index] = diary.id
+ self.diary_list.add_option(f"[b]{diary.name}[/b]\n[dim]ID: {diary.id}[/dim]")
+
+ # Maintains selection if possible
+ if current_diary_id and diary.id == current_diary_id:
+ new_selected_index = index
+
+ self.selected_diary_index = new_selected_index
+
+ # Updates highlight
+ self.set_timer(0.05, lambda: self._update_highlight(new_selected_index))
+
+ # Forces visual refresh
+ self.diary_list.refresh()
+ self.update_buttons_state()
+
+ except Exception as e:
+ self.notify(f"Error loading diaries: {str(e)}")
+
+ def _update_highlight(self, index: int):
+ """Updates the OptionList highlight"""
+ try:
+ if index < len(self.diary_list.options):
+ self.diary_list.highlighted = index
+ self.diary_list.refresh()
+ except Exception as e:
+ self.notify(f"Error updating highlight: {str(e)}")
+
+ async def async_refresh_diaries(self):
+ """Async version of refresh"""
+ if self.is_refreshing:
+ return
+
+ self.is_refreshing = True
+
+ try:
+ service_manager = self.app.service_manager
+ travel_diary_service = service_manager.get_travel_diary_service()
+
+ # Usa método síncrono agora
+ diaries = travel_diary_service.read_all()
+
+ # Saves current state
+ current_diary_id = None
+ if (self.selected_diary_index is not None and
+ self.selected_diary_index in self.diary_id_map):
+ current_diary_id = self.diary_id_map[self.selected_diary_index]
+
+ # Clears and rebuilds
+ self.diary_list.clear_options()
+ self.diary_id_map = {}
+
+ if not diaries:
+ self.diary_list.add_option("[dim]No diaries found. Press 'N' to create a new one![/dim]")
+ self.selected_diary_index = None
+ else:
+ new_selected_index = 0
+
+ for index, diary in enumerate(diaries):
+ self.diary_id_map[index] = diary.id
+ self.diary_list.add_option(f"[b]{diary.name}[/b]\n[dim]ID: {diary.id}[/dim]")
+
+ if current_diary_id and diary.id == current_diary_id:
+ new_selected_index = index
+
+ self.selected_diary_index = new_selected_index
+ self.set_timer(0.05, lambda: self._update_highlight(new_selected_index))
+
+ self.diary_list.refresh()
+ self.update_buttons_state()
+
+ except Exception as e:
+ self.notify(f"Error loading diaries: {str(e)}")
+ finally:
+ self.is_refreshing = False
+
+ def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None:
+ """Handle when an option is highlighted"""
+ if self.diary_id_map and event.option_index in self.diary_id_map:
+ self.selected_diary_index = event.option_index
+ else:
+ self.selected_diary_index = None
+
+ self.update_buttons_state()
+
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
+ """Handle when an option is selected"""
+ if self.diary_id_map and event.option_index in self.diary_id_map:
+ self.selected_diary_index = event.option_index
+ self.action_open_diary()
+ else:
+ self.selected_diary_index = None
+
+ self.update_buttons_state()
+
+ def update_buttons_state(self):
+ """Updates button states"""
+ has_selection = (self.selected_diary_index is not None and
+ self.selected_diary_index in self.diary_id_map)
+
+ self.edit_diary_button.disabled = not has_selection
+ self.open_diary.disabled = not has_selection
+
+ def on_button_pressed(self, event: Button.Pressed) -> None:
+ """Handle button clicks"""
+ button_id = event.button.id
+
+ if button_id == "new_diary":
+ self.action_new_diary()
+ elif button_id == "edit_diary":
+ self.action_edit_selected_diary()
+ elif button_id == "open_diary":
+ self.action_open_diary()
+
+ def action_new_diary(self):
+ """Action to create new diary"""
+ self.app.push_screen(NewDiaryModal(),self._on_new_diary_submitted)
+
+ def _on_new_diary_submitted(self,result):
+ self.notify(str(result))
+ if result:
+ self.notify(f"Creating Diary:{result}'...")
+ self.call_later(self._async_create_diary,result)
+ else:
+ self.notify(f"Canceled...")
+
+ async def _async_create_diary(self,name: str):
+
+ try:
+ service = self.app.service_manager.get_travel_diary_service()
+ created_diary = await service.async_create(name)
+ if created_diary:
+ self.diary_id_map[created_diary.id] = created_diary.id
+ await self.async_refresh_diaries()
+ self.notify(f"Diary: '{name}' created!")
+ else:
+ self.notify("Error Creating the diary")
+ except Exception as e:
+ self.notify(f"Exception on creating the diary: {str(e)}")
+
+
+
+ def action_edit_selected_diary(self):
+ """Action to edit selected diary"""
+ if self.selected_diary_index is not None:
+ diary_id = self.diary_id_map.get(self.selected_diary_index)
+ if diary_id:
+ self.app.push_screen(
+ EditDiaryModal(diary_id=diary_id),
+ self._on_edited_diary_name_submitted
+ )
+ else:
+ self.notify("Select a diary to edit")
+
+ def action_open_diary(self):
+ """Action to open selected diary"""
+ if self.selected_diary_index is not None:
+ diary_id = self.diary_id_map.get(self.selected_diary_index)
+ if diary_id:
+ self.app.push_screen(EditEntryScreen(diary_id=diary_id))
+ self.notify(f"Opening diary ID: {diary_id}")
+ else:
+ self.notify("Invalid diary ID")
+ else:
+ self.notify("Select a diary to open")
+
+ def _on_edited_diary_name_submitted(self, result: Optional[Tuple[int, str]]) -> None:
+ """Callback after diary editing"""
+ if result:
+ diary_id, name = result
+ self.notify(f"Updating diary ID {diary_id} to '{name}'...")
+ # Schedules async update
+ self.call_later(self._async_update_diary, diary_id, name)
+ else:
+ self.notify("Edit canceled")
+
+ async def _async_update_diary(self, diary_id: int, name: str):
+ """Updates the diary asynchronously"""
+ try:
+ service = self.app.service_manager.get_travel_diary_service()
+ updated_diary = await service.async_update(diary_id, name)
+
+ if updated_diary:
+ self.notify(f"Diary '{name}' updated!")
+ # Forces refresh after update
+ await self.async_refresh_diaries()
+ else:
+ self.notify("Error: Diary not found")
+
+ except Exception as e:
+ self.notify(f"Error updating: {str(e)}")
+
+ def action_force_refresh(self):
+ """Forces manual refresh"""
+ self.notify("Forcing refresh...")
+ # Tries both versions
+ self.refresh_diaries() # Synchronous
+ self.call_later(self.async_refresh_diaries) # Asynchronous
+
+ def action_open_selected_diary(self):
+ """Action for ENTER binding"""
+ self.action_open_diary()
+
+ def action_about_cmd(self):
+ self.app.push_screen(AboutScreen())
+
+ def action_quit(self):
+ """Action to quit the application"""
+ self.app.exit()
\ No newline at end of file
diff --git a/src/pilgrim/ui/screens/edit_diary_modal.py b/src/pilgrim/ui/screens/edit_diary_modal.py
new file mode 100644
index 0000000..4f577eb
--- /dev/null
+++ b/src/pilgrim/ui/screens/edit_diary_modal.py
@@ -0,0 +1,48 @@
+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 EditDiaryModal(ModalScreen[tuple[int,str]]):
+ BINDINGS = [
+ Binding("escape", "cancel", "Cancel"),
+ ]
+
+ def __init__(self, diary_id: int):
+ super().__init__()
+ self.diary_id = diary_id
+ self.current_diary_name = self.app.service_manager.get_travel_diary_service().read_by_id(self.diary_id).name
+ self.name_input = Input(value=self.current_diary_name, id="edit_diary_name_input", classes="EditDiaryModal-NameInput")
+
+ def compose(self) -> ComposeResult:
+ with Vertical(id="edit_diary_dialog", classes="EditDiaryModal-Dialog"):
+ yield Label("Edit Diary", classes="EditDiaryModal-Title")
+ yield Label("New Diary Name:")
+ yield self.name_input
+ with Horizontal(classes="EditDiaryModal-ButtonsContainer"):
+ yield Button("Save", variant="primary", id="save_diary_button", classes="EditDiaryModal-SaveButton")
+ yield Button("Cancel", variant="default", id="cancel_button", classes="EditDiaryModal-CancelButton")
+
+ def on_mount(self) -> None:
+ """Focuses on the input field and moves cursor to the end of text."""
+ self.name_input.focus()
+ self.name_input.cursor_position = len(self.name_input.value)
+
+ def on_button_pressed(self, event: Button.Pressed) -> None:
+ if event.button.id == "save_diary_button":
+ new_diary_name = self.name_input.value.strip()
+ if new_diary_name and new_diary_name != self.current_diary_name:
+ self.dismiss((self.diary_id, new_diary_name))
+ elif new_diary_name == self.current_diary_name:
+ self.notify("No changes made.", severity="warning")
+ self.dismiss(None)
+ else:
+ self.notify("Diary name cannot be empty.", severity="warning")
+ self.name_input.focus()
+ elif event.button.id == "cancel_button":
+ self.dismiss(None)
+
+ def action_cancel(self) -> None:
+ self.dismiss(None)
\ No newline at end of file
diff --git a/src/pilgrim/ui/screens/edit_entry_screen.py b/src/pilgrim/ui/screens/edit_entry_screen.py
new file mode 100644
index 0000000..212a7e5
--- /dev/null
+++ b/src/pilgrim/ui/screens/edit_entry_screen.py
@@ -0,0 +1,1030 @@
+import re
+from datetime import datetime
+from pathlib import Path
+from typing import Optional, List
+
+from pilgrim.models.entry import Entry
+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.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 = [
+ 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):
+ print("DEBUG: EditEntryScreen INIT")
+ super().__init__()
+ self.diary_id = diary_id
+ self.diary_name = f"Diary {diary_id}"
+ self.current_entry_index = 0
+ self.entries: List[Entry] = []
+ self.is_new_entry = False
+ self.has_unsaved_changes = False
+ self.new_entry_content = ""
+ self.new_entry_title = "New Entry"
+ self.next_entry_id = 1
+ self._updating_display = False
+ self._original_content = ""
+ self.is_refreshing = False
+ self.sidebar_visible = False
+ self.sidebar_focused = False
+ self._sidebar_opened_once = False
+ self._active_tooltip = None
+ self._last_photo_suggestion_notification = None
+ self._last_photo_suggestion_type = None
+ self._active_notification = None
+ self._notification_timer = None
+ self.references = []
+ self.cached_photos = []
+
+ # Main header
+ self.header = Header(name="Pilgrim v6", classes="EditEntryScreen-header")
+
+ # Sub-header widgets
+ self.diary_info = Static(f"Diary: {self.diary_name}", id="diary_info", classes="EditEntryScreen-diary-info")
+ self.entry_info = Static("Loading...", id="entry_info", classes="EditEntryScreen-entry-info")
+ self.status_indicator = Static("Saved", id="status_indicator", classes="EditEntryScreen-status-indicator")
+
+ # Sub-header container
+ self.sub_header = Horizontal(
+ self.diary_info,
+ Static(classes="spacer EditEntryScreen-spacer"),
+ self.entry_info,
+ self.status_indicator,
+ id="sub_header",
+ classes="EditEntryScreen-sub-header"
+ )
+
+ # 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"
+ )
+
+ # Main container
+ self.main = Container(
+ self.sub_header,
+ self.text_entry,
+ id="EditEntryScreen_MainContainer",
+ classes="EditEntryScreen-main-container"
+ )
+
+ # Footer
+ self.footer = Footer(classes="EditEntryScreen-footer")
+
+ def _update_footer_context(self):
+ """Force footer refresh to show updated bindings"""
+ self.refresh()
+
+ def _get_cursor_position(self) -> tuple:
+ """Get the current cursor position for tooltip placement"""
+ try:
+ # Get cursor position from text area
+ cursor_location = self.text_entry.cursor_location
+ if cursor_location:
+ # Get the text area region
+ text_region = self.text_entry.region
+ if text_region:
+ # Calculate position relative to text area
+ # Position tooltip below the current line, not over it
+ x = text_region.x + min(cursor_location[0], text_region.width - 40) # Keep within bounds
+ y = text_region.y + cursor_location[1] + 2 # 2 lines below cursor
+ return (x, y)
+ except:
+ pass
+ return None
+
+
+ def compose(self) -> ComposeResult:
+ print("DEBUG: EditEntryScreen COMPOSE", getattr(self, 'sidebar_visible', None))
+ yield self.header
+ yield Horizontal(
+ self.main,
+ self.sidebar,
+ id="content_container",
+ classes="EditEntryScreen-content-container"
+ )
+ yield self.footer
+
+ def on_mount(self) -> None:
+ """Called when the screen is mounted"""
+ self.sidebar.display = False
+ self.sidebar_visible = False
+
+ # First update diary info, then refresh entries
+ self.update_diary_info()
+ self.refresh_entries()
+
+ # Initialize footer with editor context
+ self._update_footer_context()
+ # self.app.mount(self._photo_suggestion_widget) # Temporarily disabled
+
+ def update_diary_info(self):
+ """Updates diary information"""
+ try:
+ service_manager = self.app.service_manager
+ travel_diary_service = service_manager.get_travel_diary_service()
+
+ diary = travel_diary_service.read_by_id(self.diary_id)
+ if diary:
+ self.diary_name = diary.name
+ self.diary_info.update(f"Diary: {self.diary_name}")
+ else:
+ self.diary_name = f"Diary {self.diary_id}"
+ self.diary_info.update(f"Diary: {self.diary_name}")
+ self.notify(f"Diary {self.diary_id} not found, using default name")
+ except Exception as e:
+ self.diary_name = f"Diary {self.diary_id}"
+ self.diary_info.update(f"Diary: {self.diary_name}")
+ self.notify(f"Error loading diary info: {str(e)}")
+
+ self._ensure_diary_info_updated()
+
+ def _ensure_diary_info_updated(self):
+ """Ensures the diary info widget is always updated with current diary name"""
+ try:
+ self.diary_info.update(f"Diary: {self.diary_name}")
+ except Exception as e:
+ self.diary_info.update(f"Diary: {self.diary_id}")
+
+ def refresh_entries(self):
+ """Synchronous version of refresh"""
+ try:
+ service_manager = self.app.service_manager
+ entry_service = service_manager.get_entry_service()
+
+ all_entries = entry_service.read_all()
+ self.entries = [entry for entry in all_entries if entry.fk_travel_diary_id == self.diary_id]
+ self.entries.sort(key=lambda x: x.id)
+
+ if self.entries:
+ self.next_entry_id = max(entry.id for entry in self.entries) + 1
+ else:
+ self.next_entry_id = 1
+
+ self._update_entry_display()
+ self._update_sub_header()
+
+ except Exception as e:
+ self.notify(f"Error loading entries: {str(e)}")
+
+ self._ensure_diary_info_updated()
+
+ def _update_status_indicator(self, text: str, css_class: str):
+ """Helper to update status indicator text and class."""
+ self.status_indicator.update(text)
+ self.status_indicator.remove_class("saved", "not-saved", "new", "read-only")
+ self.status_indicator.add_class(css_class)
+
+ def _update_sub_header(self):
+ """Updates the sub-header with current entry information."""
+ if not self.entries and not self.is_new_entry:
+ self.entry_info.update("No entries")
+ self._update_status_indicator("Saved", "saved")
+ return
+
+ if self.is_new_entry:
+ self.entry_info.update(f"New Entry: {self.new_entry_title}")
+ if self.has_unsaved_changes:
+ self._update_status_indicator("Not Saved", "not-saved")
+ else:
+ self._update_status_indicator("New", "new")
+ else:
+ current_entry = self.entries[self.current_entry_index]
+ entry_text = f"Entry: \\[{self.current_entry_index + 1}/{len(self.entries)}] {current_entry.title}"
+ self.entry_info.update(entry_text)
+ self._update_status_indicator("Saved", "saved")
+
+ def _save_current_state(self):
+ """Saves the current state before navigating"""
+ if self.is_new_entry:
+ self.new_entry_content = self.text_entry.text
+ elif self.entries and self.has_unsaved_changes:
+ current_entry = self.entries[self.current_entry_index]
+ current_entry.text = self.text_entry.text
+
+ def _finish_display_update(self):
+ """Finishes the display update by reactivating change detection"""
+ self._updating_display = False
+ self._update_sub_header()
+ if self.sidebar_visible:
+ self._update_sidebar_content()
+
+ def _update_entry_display(self):
+ """Updates the display of the current entry"""
+ if not self.entries and not self.is_new_entry:
+ self.text_entry.text = f"No entries found for diary '{self.diary_name}'\n\nPress Ctrl+N to create a new entry."
+ self.text_entry.read_only = True
+ self._original_content = self.text_entry.text
+ self._update_sub_header()
+ return
+
+ self._updating_display = True
+
+ if self.is_new_entry:
+ self.text_entry.text = self.new_entry_content
+ self.text_entry.read_only = False
+ self._original_content = self.new_entry_content
+ self.has_unsaved_changes = False
+ else:
+ current_entry = self.entries[self.current_entry_index]
+ self.text_entry.text = current_entry.text
+ self.text_entry.read_only = False
+ self._original_content = current_entry.text
+ self.has_unsaved_changes = False
+
+ 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][yellow]F9[/yellow][/b]: Switch focus (if needed)\n\n"
+ "[b]📝 Photo References[/b]\n"
+ "Use: \\[\\[photo:name:hash\\]\\]\n"
+ "Or: \\[\\[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:
+ print("DEBUG: TOGGLE SIDEBAR", self.sidebar_visible)
+ self.sidebar_visible = not 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()
+ # Notification when opening the sidebar for the first time
+ if not self._sidebar_opened_once:
+ self.notify(
+ "Sidebar opened and focused! Use the shortcuts shown in the help panel.",
+ severity="info"
+ )
+ 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
+
+ # 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
+
+ def action_toggle_focus(self):
+ """Toggles focus between editor and sidebar"""
+ print("DEBUG: TOGGLE FOCUS called", self.sidebar_visible, self.sidebar_focused)
+ if not self.sidebar_visible:
+ # If sidebar is not visible, show it and focus it
+ print("DEBUG: Sidebar not visible, opening it")
+ self.action_toggle_sidebar()
+ return
+
+ self.sidebar_focused = not self.sidebar_focused
+ print("DEBUG: Sidebar focused changed to", self.sidebar_focused)
+ if self.sidebar_focused:
+ self.photo_list.focus()
+ else:
+ self.text_entry.focus()
+
+ # 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 += f"📁 {selected_photo.filepath}\n\n"
+ photo_details += f"[b]Reference formats:[/b]\n"
+ photo_details += f"\\[\\[photo:{selected_photo.name}:{photo_hash}\\]\\]\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
+ )
+
+ 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]]:
+ """
+ Validates photo references in the text against the memory cache.
+ Checks for:
+ - Malformed references
+ - Incorrect hash length
+ - Invalid or ambiguous hashes
+ Returns a list of unique photos (no duplicates even if referenced multiple times).
+ """
+ text = self.text_entry.text
+
+ # First check for malformed references
+ malformed_pattern = r"\[\[photo::([^\]]*)\](?!\])" # Missing ] at the end
+ malformed_matches = re.findall(malformed_pattern, text)
+ if malformed_matches:
+ for match in malformed_matches:
+ self.notify(f"❌ Malformed reference: '\\[\\[photo::{match}\\]' - Missing closing '\\]'", severity="error", timeout=10)
+ return None
+
+ # Look for incorrect format references
+ invalid_format = r"\[\[photo:[^:\]]+\]\]" # [[photo:something]] without ::
+ invalid_matches = re.findall(invalid_format, text)
+ if invalid_matches:
+ for match in invalid_matches:
+ escaped_match = match.replace("[", "\\[").replace("]", "\\]")
+ self.notify(f"❌ Invalid format: '{escaped_match}' - Use '\\[\\[photo::hash\\]\\]'", severity="error", timeout=10)
+ return None
+
+ # Now look for all references to validate
+ pattern = r"\[\[photo::([^\]]+)\]\]"
+ # Use set to get unique references only
+ all_refs = set(re.findall(pattern, text))
+
+ if not all_refs:
+ return [] # No references, valid operation
+
+ self._load_photos_for_diary(self.diary_id)
+ linked_photos: List[Photo] = []
+ processed_hashes = set() # Keep track of processed hashes to avoid duplicates
+
+ for ref in all_refs:
+ # Skip if we already processed this hash
+ if ref in processed_hashes:
+ continue
+
+ # Validate hash length
+ if len(ref) != 8:
+ self.notify(
+ f"❌ Invalid hash: '{ref}' - Must be exactly 8 characters long",
+ severity="error",
+ timeout=10
+ )
+ return None
+
+ # Validate if contains only valid hexadecimal characters
+ if not re.match(r"^[0-9A-Fa-f]{8}$", ref):
+ self.notify(
+ f"❌ Invalid hash: '{ref}' - Use only hexadecimal characters (0-9, A-F)",
+ severity="error",
+ timeout=10
+ )
+ return None
+
+ # Search for photos matching the hash
+ found_photos = [p for p in self.cached_photos if p.photo_hash.startswith(ref)]
+
+ if len(found_photos) == 0:
+ self.notify(
+ f"❌ Hash not found: '{ref}' - No photo matches this hash",
+ severity="error",
+ timeout=10
+ )
+ return None
+ elif len(found_photos) > 1:
+ self.notify(
+ f"❌ Ambiguous hash: '{ref}' - Matches multiple photos",
+ severity="error",
+ timeout=10
+ )
+ return None
+ else:
+ linked_photos.append(found_photos[0])
+ processed_hashes.add(ref) # Mark this hash as processed
+
+ # Convert list to set and back to list to ensure uniqueness of photos
+ return list(set(linked_photos))
+
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
+ """Handles photo selection in the sidebar"""
+ if not self.sidebar_visible:
+ return
+
+ # Handle "Ingest Photo" option
+ if event.option_index == 0: # First option is "Ingest Photo"
+ self.action_ingest_new_photo()
+ return
+
+ photos = self._load_photos_for_diary(self.diary_id)
+ if not photos:
+ return
+
+ # Adjust index because of 'Ingest Photo' at the top
+ photo_index = event.option_index - 1
+ if photo_index >= len(photos):
+ return
+
+ selected_photo = photos[photo_index]
+ photo_hash = selected_photo.photo_hash[:8]
+ self.notify(f"Selected photo: {selected_photo.name} \\[{photo_hash}\\]")
+
+ # Update photo info with details including hash
+ photo_details = f"📷 {selected_photo.name}\n"
+ photo_details += f"🔗 {photo_hash}\n"
+ photo_details += f"📅 {selected_photo.addition_date}\n"
+ if selected_photo.caption:
+ photo_details += f"💬 {selected_photo.caption}\n"
+ photo_details += f"📁 {selected_photo.filepath}\n\n"
+ photo_details += f"[b]Reference formats:[/b]\n"
+ photo_details += f"\\[\\[photo:{selected_photo.name}:{photo_hash}\\]\\]\n"
+ photo_details += f"\\[\\[photo::{photo_hash}\\]\\]"
+
+ self.photo_info.update(photo_details)
+
+ def on_text_area_changed(self, event) -> None:
+ """Detects text changes and shows photo tooltips"""
+ if (hasattr(self, 'text_entry') and not self.text_entry.read_only and
+ not getattr(self, '_updating_display', False) and hasattr(self, '_original_content')):
+ current_content = self.text_entry.text
+
+
+
+ # Check for a photo reference pattern
+ # self._check_photo_reference(current_content) # Temporarily disabled
+
+ if current_content != self._original_content:
+ if not self.has_unsaved_changes:
+ self.has_unsaved_changes = True
+ self._update_sub_header()
+ else:
+ if self.has_unsaved_changes:
+ self.has_unsaved_changes = False
+ self._update_sub_header()
+
+ def on_focus(self, event) -> None:
+ """Captures focus changes to update footer"""
+ # Check if the focus changed to/from sidebar
+ if hasattr(event.widget, 'id'):
+ if event.widget.id == "photo_list":
+ self.sidebar_focused = True
+ self._update_footer_context()
+ elif event.widget.id == "text_entry":
+ self.sidebar_focused = False
+ self._update_footer_context()
+
+ def action_back_to_list(self) -> None:
+ """Goes back to the diary list"""
+ if self.is_new_entry and not self.text_entry.text.strip() and not self.has_unsaved_changes:
+ self.app.pop_screen()
+ self.notify("Returned to diary list")
+ return
+
+ if self.has_unsaved_changes or (self.is_new_entry and self.text_entry.text.strip()):
+ self.notify("There are unsaved changes! Use Ctrl+S to save before leaving.")
+ return
+
+ self.app.pop_screen()
+ self.notify("Returned to diary list")
+
+ def action_next_entry(self) -> None:
+ """Goes to the next entry"""
+ self._save_current_state()
+
+ if not self.entries:
+ if not self.is_new_entry:
+ self.is_new_entry = True
+ self._update_entry_display()
+ self.notify("New entry created")
+ else:
+ self.notify("Already in a new entry")
+ return
+
+ if self.is_new_entry:
+ self.notify("Already at the last position (new entry)")
+ elif self.current_entry_index < len(self.entries) - 1:
+ self.current_entry_index += 1
+ self._update_entry_display()
+ current_entry = self.entries[self.current_entry_index]
+ self.notify(f"Navigating to: {current_entry.title}")
+ else:
+ self.is_new_entry = True
+ self._update_entry_display()
+ self.notify("New entry created")
+
+ def action_prev_entry(self) -> None:
+ """Goes to the previous entry"""
+ self._save_current_state()
+
+ if not self.entries:
+ self.notify("No entries to navigate")
+ return
+
+ if self.is_new_entry:
+ if self.entries:
+ self.is_new_entry = False
+ self.current_entry_index = len(self.entries) - 1
+ self._update_entry_display()
+ current_entry = self.entries[self.current_entry_index]
+ self.notify(f"Navigating to: {current_entry.title}")
+ else:
+ self.notify("No previous entries")
+ elif self.current_entry_index > 0:
+ self.current_entry_index -= 1
+ self._update_entry_display()
+ current_entry = self.entries[self.current_entry_index]
+ self.notify(f"Navigating to: {current_entry.title}")
+ else:
+ self.notify("Already at the first entry")
+
+ def action_rename_entry(self) -> None:
+ """Opens a modal to rename the entry."""
+ if not self.entries and not self.is_new_entry:
+ self.notify("No entry to rename", severity="warning")
+ return
+
+ if self.is_new_entry:
+ current_name = self.new_entry_title
+ else:
+ current_entry = self.entries[self.current_entry_index]
+ current_name = current_entry.title
+
+ self.app.push_screen(
+ RenameEntryModal(current_name=current_name),
+ self.handle_rename_result
+ )
+
+ def handle_rename_result(self, new_name: str | None) -> None:
+ """Callback that processes the rename modal result."""
+ if new_name is None:
+ self.notify("Rename cancelled")
+ return
+
+ if not new_name.strip():
+ self.notify("Name cannot be empty", severity="error")
+ return
+
+ if self.is_new_entry:
+ old_name = self.new_entry_title
+ self.new_entry_title = new_name
+ self.notify(f"New entry title changed to '{new_name}'")
+ else:
+ current_entry = self.entries[self.current_entry_index]
+ old_name = current_entry.title
+ current_entry.title = new_name
+ self.notify(f"Title changed from '{old_name}' to '{new_name}'")
+
+ self.has_unsaved_changes = True
+ self._update_sub_header()
+
+ def action_save(self) -> None:
+ """Salva a entrada após validar e coletar as fotos referenciadas."""
+ photos_to_link = self._get_linked_photos_from_text()
+
+ if photos_to_link is None:
+ self.notify("⚠️ Saving was canceled ", severity="error")
+ return
+
+ content = self.text_entry.text.strip()
+ if self.is_new_entry:
+ if not content:
+ self.notify("Empty entry cannot be saved")
+ return
+ # Passe a lista de fotos para o método de criação
+ self.call_later(self._async_create_entry, content, photos_to_link)
+ else:
+ # Passe a lista de fotos para o método de atualização
+ self.call_later(self._async_update_entry, content, photos_to_link)
+
+ async def _async_create_entry(self, content: str, photos_to_link: List[Photo]):
+ """Creates a new entry and links the referenced photos."""
+ try:
+ service_manager = self.app.service_manager
+ entry_service = service_manager.get_entry_service()
+
+ new_entry = entry_service.create(
+ travel_diary_id=self.diary_id,
+ title=self.new_entry_title,
+ text=content,
+ date=datetime.now(),
+ photos=photos_to_link
+ )
+
+ if new_entry:
+ self.entries.append(new_entry)
+ self.entries.sort(key=lambda x: x.id)
+
+ for i, entry in enumerate(self.entries):
+ if entry.id == new_entry.id:
+ self.current_entry_index = i
+ break
+
+ self.is_new_entry = False
+ self.has_unsaved_changes = False
+ 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"Entry '{new_entry.title}' saved successfully!")
+ else:
+ self.notify("Error creating entry")
+
+ except Exception as e:
+ self.notify(f"Error creating entry: {str(e)}")
+
+ async def _async_update_entry(self, updated_content: str, photos_to_link: List[Photo]):
+ """Updates an existing entry and its photo links."""
+ try:
+ if not self.entries:
+ 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(
+ 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,
+ fk_travel_diary_id=self.diary_id
+ )
+
+ 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:
+ self.notify(f"Error updating entry: {str(e)}")
+
+ def on_key(self, event):
+
+ # Sidebar contextual shortcuts
+ if self.sidebar_focused and self.sidebar_visible:
+
+ if event.key == "i":
+
+ self.action_insert_photo()
+ event.stop()
+ elif event.key == "n":
+
+ self.action_ingest_new_photo()
+ event.stop()
+ elif event.key == "d":
+
+ self.action_delete_photo()
+ event.stop()
+ elif event.key == "e":
+
+ self.action_edit_photo()
+ event.stop()
+ # Shift+Tab: remove indent
+ elif self.focused is self.text_entry and event.key == "shift+tab":
+ textarea = self.text_entry
+ row, col = textarea.cursor_location
+ lines = textarea.text.splitlines()
+ if row < len(lines):
+ line = lines[row]
+ if line.startswith('\t'):
+ lines[row] = line[1:]
+ textarea.text = '\n'.join(lines)
+ textarea.cursor_location = (row, max(col - 1, 0))
+ elif line.startswith(' '): # 4 spaces
+ lines[row] = line[4:]
+ textarea.text = '\n'.join(lines)
+ textarea.cursor_location = (row, max(col - 4, 0))
+ elif line.startswith(' '):
+ n = len(line) - len(line.lstrip(' '))
+ to_remove = min(n, 4)
+ lines[row] = line[to_remove:]
+ textarea.text = '\n'.join(lines)
+ textarea.cursor_location = (row, max(col - to_remove, 0))
+ event.stop()
+ # Tab: insert tab
+ elif self.focused is self.text_entry and event.key == "tab":
+ self.text_entry.insert('\t')
+ event.stop()
\ No newline at end of file
diff --git a/src/pilgrim/ui/screens/modals/add_photo_modal.py b/src/pilgrim/ui/screens/modals/add_photo_modal.py
new file mode 100644
index 0000000..89181b2
--- /dev/null
+++ b/src/pilgrim/ui/screens/modals/add_photo_modal.py
@@ -0,0 +1,124 @@
+import os
+from pathlib import Path
+from textual.app import ComposeResult
+from textual.screen import Screen
+from textual.widgets import Static, Input, Button
+from textual.containers import Horizontal, Container
+from .file_picker_modal import FilePickerModal
+import hashlib
+
+class AddPhotoModal(Screen):
+ """Modal for adding a new photo"""
+ def __init__(self, diary_id: int):
+ super().__init__()
+ self.diary_id = diary_id
+ self.result = None
+ self.created_photo = None
+
+ def _generate_photo_hash(self, photo_data: dict) -> str:
+ """Generate a short, unique hash for a photo"""
+ # Use temporary data for hash generation
+ unique_string = f"{photo_data['name']}_{photo_data.get('photo_id', 0)}_new"
+ hash_object = hashlib.md5(unique_string.encode())
+ return hash_object.hexdigest()[:8]
+
+ def compose(self) -> ComposeResult:
+ yield Container(
+ Static("📷 Add New Photo", classes="AddPhotoModal-Title"),
+ Static("File path:", classes="AddPhotoModal-Label"),
+ Horizontal(
+ Input(placeholder="Enter file path...", id="filepath-input", classes="AddPhotoModal-Input"),
+ Button("Escolher arquivo...", id="choose-file-button", classes="AddPhotoModal-Button"),
+ classes="AddPhotoModal-FileRow"
+ ),
+ Static("Photo name:", classes="AddPhotoModal-Label"),
+ Input(placeholder="Enter photo name...", id="name-input", classes="AddPhotoModal-Input"),
+ Static("Caption (optional):", classes="AddPhotoModal-Label"),
+ Input(placeholder="Enter caption...", id="caption-input", classes="AddPhotoModal-Input"),
+ Horizontal(
+ Button("Add Photo", id="add-button", classes="AddPhotoModal-Button"),
+ Button("Cancel", id="cancel-button", classes="AddPhotoModal-Button"),
+ classes="AddPhotoModal-Buttons"
+ ),
+ classes="AddPhotoModal-Dialog"
+ )
+
+ def on_button_pressed(self, event: Button.Pressed) -> None:
+ if event.button.id == "choose-file-button":
+ self.app.push_screen(
+ FilePickerModal(),
+ self.handle_file_picker_result
+ )
+ return
+ if event.button.id == "add-button":
+ filepath = self.query_one("#filepath-input", Input).value
+ name = self.query_one("#name-input", Input).value
+ caption = self.query_one("#caption-input", Input).value
+ if not filepath.strip() or not name.strip():
+ self.notify("File path and name are required", severity="error")
+ return
+
+ # Try to create the photo in the database
+ self.call_later(self._async_create_photo, {
+ "filepath": filepath.strip(),
+ "name": name.strip(),
+ "caption": caption.strip() if caption.strip() else None
+ })
+ elif event.button.id == "cancel-button":
+ self.dismiss()
+
+ async def _async_create_photo(self, photo_data: dict):
+ """Creates a new photo asynchronously using PhotoService"""
+ try:
+ service_manager = self.app.service_manager
+ photo_service = service_manager.get_photo_service()
+
+ new_photo = photo_service.create(
+ filepath=Path(photo_data["filepath"]),
+ name=photo_data["name"],
+ travel_diary_id=self.diary_id,
+ caption=photo_data["caption"]
+ )
+
+ if new_photo:
+ self.created_photo = new_photo
+ # Generate hash for the new photo
+ photo_hash = self._generate_photo_hash({
+ "name": new_photo.name,
+ "photo_id": new_photo.id
+ })
+
+ self.notify(f"Photo '{new_photo.name}' added successfully!\nHash: {photo_hash}\nReference: \\[\\[photo:{new_photo.name}:{photo_hash}\\]\\]",
+ severity="information", timeout=5)
+
+ # Return the created photo data to the calling screen
+ self.result = {
+ "filepath": photo_data["filepath"],
+ "name": photo_data["name"],
+ "caption": photo_data["caption"],
+ "photo_id": new_photo.id,
+ "hash": photo_hash
+ }
+ self.dismiss(self.result)
+ else:
+ self.notify("Error creating photo in database", severity="error")
+
+ except Exception as e:
+ self.notify(f"Error creating photo: {str(e)}", severity="error")
+
+ def handle_file_picker_result(self, result: str | None) -> None:
+ if result:
+ # Set the filepath input value
+ filepath_input = self.query_one("#filepath-input", Input)
+ filepath_input.value = result
+ # Trigger the input change event to update the UI
+ filepath_input.refresh()
+ # Auto-fill the name field with the filename (without extension)
+ filename = Path(result).stem
+ name_input = self.query_one("#name-input", Input)
+ if not name_input.value.strip():
+ name_input.value = filename
+ name_input.refresh()
+ else:
+ # User cancelled the file picker
+ self.notify("File selection cancelled", severity="information")
\ No newline at end of file
diff --git a/src/pilgrim/ui/screens/modals/confirm_delete_modal.py b/src/pilgrim/ui/screens/modals/confirm_delete_modal.py
new file mode 100644
index 0000000..b987de4
--- /dev/null
+++ b/src/pilgrim/ui/screens/modals/confirm_delete_modal.py
@@ -0,0 +1,32 @@
+from textual.app import ComposeResult
+from textual.screen import Screen
+from textual.widgets import Static, Button
+from textual.containers import Container, Horizontal
+from pilgrim.models.photo import Photo
+
+class ConfirmDeleteModal(Screen):
+ """Modal for confirming photo deletion"""
+ def __init__(self, photo: Photo):
+ super().__init__()
+ self.photo = photo
+ self.result = None
+
+ def compose(self) -> ComposeResult:
+ yield Container(
+ Static("🗑️ Confirm Deletion", classes="ConfirmDeleteModal-Title"),
+ Static(f"Are you sure you want to delete the photo '{self.photo.name}'?", classes="ConfirmDeleteModal-Message"),
+ Static("This action cannot be undone.", classes="ConfirmDeleteModal-Warning"),
+ Horizontal(
+ Button("Delete", variant="error", id="delete-button", classes="ConfirmDeleteModal-Button"),
+ Button("Cancel", variant="default", id="cancel-button", classes="ConfirmDeleteModal-Button"),
+ classes="ConfirmDeleteModal-Buttons"
+ ),
+ classes="ConfirmDeleteModal-Dialog"
+ )
+
+ def on_button_pressed(self, event: Button.Pressed) -> None:
+ if event.button.id == "delete-button":
+ self.result = True
+ self.dismiss(True)
+ elif event.button.id == "cancel-button":
+ self.dismiss(False)
\ No newline at end of file
diff --git a/src/pilgrim/ui/screens/modals/edit_photo_modal.py b/src/pilgrim/ui/screens/modals/edit_photo_modal.py
new file mode 100644
index 0000000..9a44d1d
--- /dev/null
+++ b/src/pilgrim/ui/screens/modals/edit_photo_modal.py
@@ -0,0 +1,82 @@
+from textual.app import ComposeResult
+from textual.screen import Screen
+from textual.widgets import Static, Input, Button
+from textual.containers import Container, Horizontal
+from pilgrim.models.photo import Photo
+import hashlib
+
+class EditPhotoModal(Screen):
+ """Modal for editing an existing photo (name and caption only)"""
+ def __init__(self, photo: Photo):
+ super().__init__()
+ self.photo = photo
+ self.result = None
+
+ def _generate_photo_hash(self, photo: Photo) -> str:
+ """Generate a short, unique hash for a photo"""
+ unique_string = f"{photo.name}_{photo.id}_{photo.addition_date}"
+ hash_object = hashlib.md5(unique_string.encode())
+ return hash_object.hexdigest()[:8]
+
+ def compose(self) -> ComposeResult:
+ # Generate hash for this photo
+ photo_hash = self._generate_photo_hash(self.photo)
+
+ yield Container(
+ Static("✏️ Edit Photo", classes="EditPhotoModal-Title"),
+ Static("File path (read-only):", classes="EditPhotoModal-Label"),
+ Input(
+ value=self.photo.filepath,
+ id="filepath-input",
+ classes="EditPhotoModal-Input",
+ disabled=True
+ ),
+ Static("Photo name:", classes="EditPhotoModal-Label"),
+ Input(
+ value=self.photo.name,
+ placeholder="Enter photo name...",
+ id="name-input",
+ classes="EditPhotoModal-Input"
+ ),
+ Static("Caption (optional):", classes="EditPhotoModal-Label"),
+ Input(
+ value=self.photo.caption or "",
+ placeholder="Enter caption...",
+ id="caption-input",
+ classes="EditPhotoModal-Input"
+ ),
+ Static(f"🔗 Photo Hash: {photo_hash}", classes="EditPhotoModal-Hash"),
+ Static("Reference formats:", classes="EditPhotoModal-Label"),
+ Static(f"\\[\\[photo:{self.photo.name}:{photo_hash}\\]\\]", classes="EditPhotoModal-Reference"),
+ Static(f"\\[\\[photo::{photo_hash}\\]\\]", classes="EditPhotoModal-Reference"),
+ Horizontal(
+ Button("Save Changes", id="save-button", classes="EditPhotoModal-Button"),
+ Button("Cancel", id="cancel-button", classes="EditPhotoModal-Button"),
+ classes="EditPhotoModal-Buttons"
+ ),
+ classes="EditPhotoModal-Dialog"
+ )
+
+ def on_button_pressed(self, event: Button.Pressed) -> None:
+ if event.button.id == "save-button":
+ name = self.query_one("#name-input", Input).value
+ caption = self.query_one("#caption-input", Input).value
+
+ if not name.strip():
+ self.notify("Photo name is required", severity="error")
+ return
+
+ # Return the updated photo data
+ self.result = {
+ "filepath": self.photo.filepath, # Keep original filepath
+ "name": name.strip(),
+ "caption": caption.strip() if caption.strip() else None
+ }
+ self.dismiss(self.result)
+
+ elif event.button.id == "cancel-button":
+ self.dismiss()
+
+ def on_mount(self) -> None:
+ """Focus on the name input when modal opens"""
+ self.query_one("#name-input", Input).focus()
\ No newline at end of file
diff --git a/src/pilgrim/ui/screens/modals/file_picker_modal.py b/src/pilgrim/ui/screens/modals/file_picker_modal.py
new file mode 100644
index 0000000..c65f206
--- /dev/null
+++ b/src/pilgrim/ui/screens/modals/file_picker_modal.py
@@ -0,0 +1,66 @@
+import os
+from pathlib import Path
+from typing import Iterable
+from textual.app import ComposeResult
+from textual.screen import Screen
+from textual.widgets import Static, DirectoryTree, Button
+from textual.containers import Horizontal, Container
+
+class ImageDirectoryTree(DirectoryTree):
+ """DirectoryTree that only shows image files"""
+
+ def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]:
+ """Filter to show only directories and image files"""
+ image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'}
+ return [
+ path for path in paths
+ if path.is_dir() or path.suffix.lower() in image_extensions
+ ]
+
+class FilePickerModal(Screen):
+ """Modal for picking an image file using DirectoryTree"""
+
+ def __init__(self, start_path=None):
+ super().__init__()
+ self.start_path = Path(start_path or os.getcwd())
+ # Start one level up to make navigation easier
+ self.current_path = self.start_path.parent
+
+ def compose(self) -> ComposeResult:
+ yield Container(
+ Static(f"Current: {self.current_path}", id="title", classes="FilePickerModal-Title"),
+ ImageDirectoryTree(str(self.current_path), id="directory-tree"),
+ Horizontal(
+ Button("Up", id="up-button", classes="FilePickerModal-Button"),
+ Button("Cancel", id="cancel-button", classes="FilePickerModal-Button"),
+ classes="FilePickerModal-Buttons"
+ ),
+ classes="FilePickerModal-Dialog"
+ )
+
+ def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected) -> None:
+ """Handle file selection"""
+ file_path = event.path
+ # Check if it's an image file
+ image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'}
+ if file_path.suffix.lower() in image_extensions:
+ # Return the file path as result
+ self.dismiss(str(file_path))
+ else:
+ self.notify("Please select an image file", severity="warning")
+
+ def on_button_pressed(self, event: Button.Pressed) -> None:
+ """Handle button presses"""
+ if event.button.id == "up-button":
+ # Navigate to parent directory
+ parent = self.current_path.parent
+ if parent != self.current_path:
+ self.current_path = parent
+ self.query_one("#title", Static).update(f"Current: {self.current_path}")
+ # Reload the directory tree
+ tree = self.query_one("#directory-tree", ImageDirectoryTree)
+ tree.path = str(self.current_path)
+ tree.reload()
+ elif event.button.id == "cancel-button":
+ # Return None to indicate cancellation
+ self.dismiss(None)
\ No newline at end of file
diff --git a/src/pilgrim/ui/screens/new_diary_modal.py b/src/pilgrim/ui/screens/new_diary_modal.py
new file mode 100644
index 0000000..2695216
--- /dev/null
+++ b/src/pilgrim/ui/screens/new_diary_modal.py
@@ -0,0 +1,45 @@
+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 = [
+ Binding("escape", "cancel", "Cancel"),
+ ]
+ def __init__(self):
+ super().__init__()
+ self.name_input = Input(id="NewDiaryModal-NameInput",classes="NewDiaryModal-NameInput") # This ID is fine, it's specific to the input
+
+ def compose(self) -> ComposeResult:
+
+ with Vertical(id="new_diary_dialog",classes="NewDiaryModal-Dialog"):
+ yield Label("Create a new diary", classes="NewDiaryModal-Title")
+ yield Label("Diary Name:")
+ yield self.name_input
+ with Horizontal(classes="NewDiaryModal-ButtonsContainer"):
+ yield Button("Create", variant="primary", id="create_diary_button",
+ classes="NewDiaryModal-CreateDiaryButton")
+ yield Button("Cancel", variant="default", id="cancel_button",
+ classes="NewDiaryModal-CancelButton")
+
+ def on_mount(self):
+ self.name_input.focus()
+
+ def on_button_pressed(self, event: Button.Pressed) -> None:
+ """Handles button clicks."""
+ if event.button.id == "create_diary_button":
+ diary_name = self.name_input.value.strip()
+ if diary_name:
+ self.dismiss(diary_name)
+ else:
+ self.notify("Diary name cannot be empty.", severity="warning")
+ self.name_input.focus()
+ elif event.button.id == "cancel_button":
+ self.dismiss("")
+
+ def action_cancel(self) -> None:
+ """Action to cancel the modal."""
+ self.dismiss("")
\ No newline at end of file
diff --git a/src/pilgrim/ui/screens/rename_entry_modal.py b/src/pilgrim/ui/screens/rename_entry_modal.py
new file mode 100644
index 0000000..0a906d6
--- /dev/null
+++ b/src/pilgrim/ui/screens/rename_entry_modal.py
@@ -0,0 +1,59 @@
+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 RenameEntryModal(ModalScreen[str]):
+ """A modal screen to rename a diary entry."""
+
+ BINDINGS = [
+ Binding("escape", "cancel", "Cancel"),
+ ]
+
+ def __init__(self, current_name: str):
+ super().__init__()
+ self._current_name = current_name
+ self.name_input = Input(
+ value=self._current_name,
+ placeholder="Type the new name...",
+ id="rename_input",
+ classes="RenameEntryModal-name-input"
+ )
+
+ def compose(self) -> ComposeResult:
+ with Vertical(id="rename_entry_dialog", classes="RenameEntryModal-dialog"):
+ yield Label("Rename Entry", classes="dialog-title RenameEntryModal-title")
+ yield Label("New Entry Title:", classes="RenameEntryModal-label")
+ yield self.name_input
+ with Horizontal(classes="dialog-buttons RenameEntryModal-buttons"):
+ yield Button("Save", variant="primary", id="save", classes="RenameEntryModal-save-button")
+ yield Button("Cancel", variant="default", id="cancel", classes="RenameEntryModal-cancel-button")
+
+ def on_mount(self) -> None:
+ """Focuses on the input when the screen is mounted."""
+ self.name_input.focus()
+ self.name_input.cursor_position = len(self.name_input.value)
+
+ def on_button_pressed(self, event: Button.Pressed) -> None:
+ """Handles button clicks."""
+ if event.button.id == "save":
+ new_name = self.name_input.value.strip()
+ if new_name:
+ self.dismiss(new_name) # Returns the new name
+ else:
+ self.dismiss(None) # Considers empty name as cancellation
+ else:
+ self.dismiss(None) # Returns None for cancellation
+
+ def on_input_submitted(self, event: Input.Submitted) -> None:
+ """Allows saving by pressing Enter."""
+ new_name = event.value.strip()
+ if new_name:
+ self.dismiss(new_name)
+ else:
+ self.dismiss(None)
+
+ def action_cancel(self) -> None:
+ self.dismiss(None)
\ No newline at end of file
diff --git a/src/pilgrim/ui/styles/pilgrim.css b/src/pilgrim/ui/styles/pilgrim.css
new file mode 100644
index 0000000..5b52192
--- /dev/null
+++ b/src/pilgrim/ui/styles/pilgrim.css
@@ -0,0 +1,629 @@
+Screen {
+ layout: vertical;
+ background: $surface-darken-1;
+ align: center middle;
+}
+
+.EditEntryScreen-sub-header {
+ layout: horizontal;
+ background: $primary-darken-1;
+ height: 1;
+ padding: 0 1;
+ align: center middle;
+ width: 100%;
+}
+
+.EditEntryScreen-diary-info {
+ width: auto;
+ color: $text-muted;
+ margin-right: 2;
+}
+
+.EditEntryScreen-entry-info {
+ width: auto;
+ color: $text;
+ margin-right: 1;
+}
+
+.EditEntryScreen-spacer {
+ width: 1fr;
+}
+
+.EditEntryScreen-status-indicator {
+ width: auto;
+ padding: 0 1;
+ content-align: center middle;
+}
+
+.EditEntryScreen-status-indicator.saved {
+ background: #2E8B57; /* SeaGreen */
+ color: $text;
+}
+
+.EditEntryScreen-status-indicator.not-saved {
+ background: #B22222; /* FireBrick */
+ color: $text;
+}
+
+.EditEntryScreen-status-indicator.new {
+ background: #4682B4; /* SteelBlue */
+ color: $text;
+}
+
+.EditEntryScreen-status-indicator.read-only {
+ background: #696969; /* DimGray */
+ color: $text-muted;
+}
+
+.EditEntryScreen-main-container {
+ background: bisque;
+ margin: 0;
+}
+
+.EditEntryScreen-text-entry {
+ width: 100%;
+ height: 1fr;
+ border: none;
+}
+
+AboutScreen {
+ align: center middle;
+}
+
+#AboutScreen_AboutContainer {
+ align: center middle;
+ width: 85%;
+ height: 95%;
+ border: thick $primary;
+ background: $surface;
+ margin: 1;
+}
+
+#AboutScreen_AboutContainer TextArea {
+ align: center middle;
+ width: 100%;
+ height: 55%;
+ margin: 2;
+ padding: 1;
+}
+
+#AboutScreen_SubContainer {
+ align: center middle;
+ height: 45%;
+ padding: 1 1;
+ margin: 1 1;
+}
+
+#AboutScreen_AboutTitle {
+ color: $accent;
+ text-style: bold;
+ text-align: center;
+ margin-bottom: 1;
+}
+
+#AboutScreen_AboutVersion {
+ color: $warning;
+ text-style: italic;
+ text-align: center;
+ margin-bottom: 1;
+}
+
+#AboutScreen_AboutContent {
+ text-align: center;
+ margin-bottom: 1;
+}
+
+#AboutScreen_AboutContact,
+#AboutScreen_AboutAuthor {
+ color: $text-muted;
+ text-align: center;
+ margin-bottom: 1;
+}
+
+#AboutScreen_AboutLicense {
+ height: 60%;
+}
+
+/* Main container - distributing vertical space */
+.DiaryListScreen-DiaryListContainer {
+ height: 1fr;
+ width: 100%;
+ layout: vertical;
+ align: center top; /* Aligns at the top to follow the wireframe */
+ padding: 2 4; /* More space on the sides */
+ background: $surface; /* Removing aquamarine to keep it cleaner */
+}
+
+/* Diary list - main area as in the wireframe */
+.DiaryListScreen-DiaryListOptions {
+ border: round $primary-lighten-2;
+ background: $surface;
+ width: 75%; /* Generous width as in the wireframe */
+ height: 60%; /* Occupies available space */
+ margin: 2 0; /* Vertical margin to separate from other elements */
+}
+
+.DiaryListScreen-DiaryListOptions:focus {
+ border: round $primary;
+}
+
+/* Button container - right below the list */
+.DiaryListScreen-ButtonsGrid {
+ layout: horizontal;
+ align: center middle;
+ width: 75%; /* Same width as the diary list */
+ height: 20%; /* Increasing height to fit button text */
+}
+
+/* Individual buttons - ensuring space for text */
+.DiaryListScreen-NewDiaryButton,
+.DiaryListScreen-EditDiaryButton,
+.DiaryListScreen-OpenDiaryButton {
+ margin: 0 1 1 0; /* Space between buttons */
+ height: 1fr;
+ width: 1fr;
+ border: round grey;
+ text-align: center;
+ content-align: center middle;
+}
+
+/* Tips - using the correct class */
+.DiaryListScreen-DiaryListTips {
+ width: 100%; /* Full width when using dock */
+ height: auto;
+ text-style: italic;
+ text-align: center; /* Centers text within the element */
+ color: $text-muted;
+ dock: bottom; /* Sticks to the bottom of the screen */
+ margin: 0 1;
+}
+
+/* Generic styles that haven't changed */
+.option-list--option-highlighted {
+ background: $primary-darken-2;
+}
+
+.option-list--option-selected {
+ text-style: bold;
+ background: $primary;
+}
+
+Header {
+ background: $primary;
+ color: $text;
+}
+
+Footer {
+ background: $primary;
+ color: $text;
+}
+
+Screen.-modal {
+ background: rgba(0, 0, 0, 0.7); /* Darkens the background screen underneath */
+ align: center middle; /* Centers the content (your modal) that is in this modal screen */
+}
+
+/* Style for the new diary modal dialog */
+.NewDiaryModal-dialog {
+ layout: vertical;
+ width: 60%;
+ height: auto;
+ background: $surface;
+ border: thick $accent;
+ padding: 2 4;
+ align: center middle;
+}
+
+.NewDiaryModal-title {
+ text-align: center;
+ text-style: bold;
+ color: $primary;
+ margin-bottom: 1;
+}
+
+.NewDiaryModal-label {
+ margin-bottom: 1;
+}
+
+.NewDiaryModal-name-input {
+ width: 1fr;
+ margin-bottom: 2;
+}
+
+.NewDiaryModal-buttons {
+ width: 1fr;
+ height: auto;
+ align: center middle;
+ padding-top: 1;
+}
+
+.NewDiaryModal-buttons Button {
+ margin: 0 1;
+ width: 1fr;
+}
+
+/* Additional classes for NewDiaryModal */
+.NewDiaryModal-Dialog {
+ layout: vertical;
+ width: 60%;
+ height: auto;
+ background: $surface;
+ border: thick $accent;
+ padding: 2 4;
+ align: center middle;
+}
+
+.NewDiaryModal-Title {
+ text-align: center;
+ text-style: bold;
+ color: $primary;
+ margin-bottom: 1;
+}
+
+.NewDiaryModal-NameInput {
+ width: 1fr;
+ margin-bottom: 2;
+}
+
+.NewDiaryModal-ButtonsContainer {
+ width: 1fr;
+ height: auto;
+ align: center middle;
+ padding-top: 1;
+}
+
+.NewDiaryModal-CreateDiaryButton,
+.NewDiaryModal-CancelButton {
+ margin: 0 1;
+ width: 1fr;
+}
+
+/* Style for the edit diary modal dialog */
+.EditDiaryModal-dialog {
+ layout: vertical;
+ width: 60%;
+ height: auto;
+ background: $surface;
+ border: thick $warning;
+ padding: 2 4;
+ align: center middle;
+}
+
+.EditDiaryModal-title {
+ text-align: center;
+ text-style: bold;
+ color: $warning;
+ margin-bottom: 1;
+}
+
+.EditDiaryModal-label {
+ margin-bottom: 1;
+}
+
+.EditDiaryModal-name-input {
+ width: 1fr;
+ margin-bottom: 2;
+}
+
+.EditDiaryModal-buttons {
+ width: 1fr;
+ height: auto;
+ align: center middle;
+ padding-top: 1;
+}
+
+.EditDiaryModal-buttons Button {
+ margin: 0 1;
+ width: 1fr;
+}
+
+/* Additional classes for EditDiaryModal */
+.EditDiaryModal-Dialog {
+ layout: vertical;
+ width: 60%;
+ height: auto;
+ background: $surface;
+ border: thick $warning;
+ padding: 2 4;
+ align: center middle;
+}
+
+.EditDiaryModal-Title {
+ text-align: center;
+ text-style: bold;
+ color: $warning;
+ margin-bottom: 1;
+}
+
+.EditDiaryModal-NameInput {
+ width: 1fr;
+ margin-bottom: 2;
+}
+
+.EditDiaryModal-ButtonsContainer {
+ width: 1fr;
+ height: auto;
+ align: center middle;
+ padding-top: 1;
+}
+
+.EditDiaryModal-SaveButton,
+.EditDiaryModal-CancelButton {
+ margin: 0 1;
+ width: 1fr;
+}
+
+/* Style for the rename entry modal dialog */
+.RenameEntryModal-dialog {
+ layout: vertical;
+ width: 60%;
+ height: auto;
+ background: $surface;
+ border: thick $accent;
+ padding: 2 4;
+ align: center middle;
+}
+
+.RenameEntryModal-title {
+ text-align: center;
+ text-style: bold;
+ color: $accent;
+ margin-bottom: 1;
+}
+
+.RenameEntryModal-name-input {
+ width: 1fr;
+ margin-bottom: 2;
+}
+
+.RenameEntryModal-buttons {
+ width: 1fr;
+ height: auto;
+ align: center middle;
+ padding-top: 1;
+}
+
+.RenameEntryModal-save-button,
+.RenameEntryModal-cancel-button {
+ margin: 0 1;
+ width: 1fr;
+}
+
+.EditEntryScreen-sidebar {
+ width: 40;
+ min-height: 10;
+ border-left: solid green;
+ padding: 1;
+ background: $surface-darken-2;
+ color: $primary;
+ text-style: bold;
+ content-align: left top;
+}
+
+.EditEntryScreen-content-container {
+ layout: horizontal;
+ height: 1fr;
+}
+
+.EditEntryScreen-sidebar-title {
+ text-align: center;
+ text-style: bold;
+ color: $accent;
+ padding: 1;
+ border-bottom: solid $accent;
+ margin-bottom: 1;
+}
+
+.EditEntryScreen-sidebar-content {
+ height: 1fr;
+ layout: vertical;
+}
+
+.EditEntryScreen-sidebar-photo-list {
+ height: 1fr;
+ border: solid $accent;
+ margin-bottom: 1;
+}
+
+.EditEntryScreen-sidebar-photo-info {
+ height: auto;
+ min-height: 3;
+ border: solid $warning;
+ padding: 1;
+ margin-bottom: 1;
+ background: $surface-darken-1;
+}
+
+.EditEntryScreen-sidebar-help {
+ height: auto;
+ min-height: 8;
+ border: solid $success;
+ padding: 1;
+ background: $surface-darken-1;
+ text-style: italic;
+}
+
+/* Photo Modal Styles */
+.modal-dialog {
+ layout: vertical;
+ width: 60%;
+ height: auto;
+ background: $surface;
+ border: thick $accent;
+ padding: 2 4;
+ align: center middle;
+}
+
+.modal-title {
+ text-align: center;
+ text-style: bold;
+ color: $primary;
+ margin-bottom: 1;
+}
+
+.modal-label {
+ margin-bottom: 1;
+ color: $text;
+}
+
+.modal-input {
+ width: 1fr;
+ margin-bottom: 2;
+}
+
+.modal-buttons {
+ width: 1fr;
+ height: auto;
+ align: center middle;
+ padding-top: 1;
+}
+
+.modal-button {
+ margin: 0 1;
+ width: 1fr;
+}
+
+/* AddPhotoModal styles */
+.AddPhotoModal-Dialog {
+ layout: vertical;
+ width: 60%;
+ height: auto;
+ background: $surface;
+ border: thick $accent;
+ padding: 2 4;
+ align: center middle;
+}
+.AddPhotoModal-Title {
+ text-align: center;
+ text-style: bold;
+ color: $primary;
+ margin-bottom: 1;
+}
+.AddPhotoModal-Label {
+ margin-bottom: 1;
+ color: $text;
+}
+.AddPhotoModal-Input {
+ width: 1fr;
+ margin-bottom: 2;
+}
+.AddPhotoModal-Buttons {
+ width: 1fr;
+ height: auto;
+ align: center middle;
+ padding-top: 1;
+}
+.AddPhotoModal-Button {
+ margin: 0 1;
+ width: 1fr;
+}
+
+/* EditPhotoModal styles */
+.EditPhotoModal-Dialog {
+ layout: vertical;
+ width: 60%;
+ height: auto;
+ background: $surface;
+ border: thick $accent;
+ padding: 2 4;
+ align: center middle;
+}
+.EditPhotoModal-Title {
+ text-align: center;
+ text-style: bold;
+ color: $primary;
+ margin-bottom: 1;
+}
+.EditPhotoModal-Label {
+ margin-bottom: 1;
+ color: $text;
+}
+.EditPhotoModal-Input {
+ width: 1fr;
+ margin-bottom: 2;
+}
+.EditPhotoModal-Buttons {
+ width: 1fr;
+ height: auto;
+ align: center middle;
+ padding-top: 1;
+}
+.EditPhotoModal-Button {
+ margin: 0 1;
+ width: 1fr;
+}
+
+/* FilePickerModal styles */
+.FilePickerModal-Dialog {
+ layout: vertical;
+ width: 80%;
+ height: 80%;
+ background: $surface;
+ border: thick $accent;
+ padding: 2 4;
+ align: center middle;
+}
+
+.FilePickerModal-Title {
+ text-align: center;
+ text-style: bold;
+ color: $primary;
+ margin-bottom: 1;
+}
+
+.FilePickerModal-Buttons {
+ width: 1fr;
+ height: auto;
+ align: center middle;
+ padding-top: 1;
+}
+
+.FilePickerModal-Button {
+ margin: 0 1;
+ width: 1fr;
+}
+
+/* DirectoryTree specific styles */
+#directory-tree {
+ height: 1fr;
+ border: solid $accent;
+ margin: 1;
+}
+
+/* ConfirmDeleteModal styles */
+.ConfirmDeleteModal-Dialog {
+ layout: vertical;
+ width: 60%;
+ height: auto;
+ background: $surface;
+ border: thick $error;
+ padding: 2 4;
+ align: center middle;
+}
+.ConfirmDeleteModal-Title {
+ text-align: center;
+ text-style: bold;
+ color: $error;
+ margin-bottom: 1;
+}
+.ConfirmDeleteModal-Message {
+ text-align: center;
+ color: $text;
+ margin-bottom: 1;
+}
+.ConfirmDeleteModal-Warning {
+ text-align: center;
+ color: $warning;
+ text-style: italic;
+ margin-bottom: 2;
+}
+.ConfirmDeleteModal-Buttons {
+ width: 1fr;
+ height: auto;
+ align: center middle;
+ padding-top: 1;
+}
+.ConfirmDeleteModal-Button {
+ margin: 0 1;
+ width: 1fr;
+}
\ No newline at end of file
diff --git a/src/pilgrim/ui/ui.py b/src/pilgrim/ui/ui.py
new file mode 100644
index 0000000..2443f74
--- /dev/null
+++ b/src/pilgrim/ui/ui.py
@@ -0,0 +1,58 @@
+from pathlib import Path
+from typing import Iterable
+
+from textual.app import App, SystemCommand
+from textual.screen import Screen
+
+
+from pilgrim.service.servicemanager import ServiceManager
+from pilgrim.ui.screens.about_screen import AboutScreen
+from pilgrim.ui.screens.diary_list_screen import DiaryListScreen
+from pilgrim.ui.screens.edit_entry_screen import EditEntryScreen
+
+CSS_FILE_PATH = Path(__file__).parent / "styles" / "pilgrim.css"
+
+
+class UIApp(App):
+ CSS_PATH = CSS_FILE_PATH
+
+ def __init__(self,service_manager: ServiceManager, **kwargs):
+ super().__init__(**kwargs)
+ self.service_manager = service_manager
+
+
+ def on_mount(self) -> None:
+ """Called when the app starts. Loads the main screen."""
+ self.push_screen(DiaryListScreen())
+
+ def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
+ """Return commands based on current screen."""
+
+ # Commands for DiaryListScreen
+ if isinstance(screen, DiaryListScreen):
+ yield SystemCommand(
+ "About Pilgrim",
+ "Open About Pilgrim",
+ screen.action_about_cmd
+ )
+
+ elif isinstance(screen, AboutScreen):
+ yield SystemCommand(
+ "Back to List",
+ "Return to the diary list",
+ screen.dismiss
+ )
+
+ elif isinstance(screen, EditEntryScreen):
+ yield SystemCommand(
+ "Back to Diary List",
+ "Return to the diary list",
+ screen.action_back_to_list
+ )
+
+ # Always include quit command
+ yield SystemCommand(
+ "Quit Application",
+ "Exit Pilgrim",
+ self.action_quit
+ )
\ No newline at end of file
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"