Compare commits

..
Author SHA1 Message Date
Gustavo Henrique Santos Souza de Miranda 77e191f480 test(entry_service): add test for creating an entry without photos
This commit adds a unit test to ensure that the EntryService's
`create` method correctly handles cases where an empty list of photos
is provided.

It verifies that the entry is created successfully and that the
`photos` relationship is an empty list, confirming the feature's
flexibility.
2025-07-20 16:03:45 -03:00
Gustavo Henrique Santos Souza de Miranda 3c7925cb1c test(entry_service): add a failure case test for create method
This commit adds a unit test for an important "unhappy path" in the
EntryService's `create` method.

It specifically verifies that the method gracefully returns `None`
when provided with a `travel_diary_id` that does not exist in the
database, ensuring the initial guard clause works as expected.
2025-07-20 15:21:49 -03:00
Gustavo Henrique Santos Souza de Miranda c4dceda942 test(entry_service): add unit test for creating a new entry
This commit introduces the first unit test for the EntryService,
covering the "happy path" for the `create` method.

It verifies that a new entry is correctly persisted to the database,
including its relationship with associated Photo objects. The test
leverages fixtures to create an isolated, in-memory database
populated with the necessary dependencies.
2025-07-20 03:35:22 -03:00
Gustavo Henrique Miranda 4d87ae1b90 Merge pull request #61 from gmbrax/fix/relative-imports
Fix/relative imports
2025-07-20 03:27:31 -03:00
Gustavo Henrique Mirandaandcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> e1add84537 Update src/pilgrim/database.py
Remove Debug message

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-07-20 03:23:37 -03:00
Gustavo Henrique Santos Souza de Miranda 2ba1cbd0a6 Fix the declarative_base import to the newer way 2025-07-20 03:10:13 -03:00
Gustavo Henrique Santos Souza de Miranda eed16f34c1 Simplify the pyproject.toml 2025-07-20 02:47:05 -03:00
Gustavo Henrique Santos Souza de Miranda 06ee08b201 Fix all imports to be absolute and not relative 2025-07-20 02:46:46 -03:00
Gustavo Henrique Miranda 78e45e26fd Update pylint_sonarqube.yml
Fix the ruff error on the sonarcloud scan
2025-07-19 19:06:53 -03:00
Gustavo Henrique Miranda cc68bbcf2e Update pylint_sonarqube.yml
Add the missing parameters for SonarQube
2025-07-19 18:56:19 -03:00
Gustavo Henrique Miranda 9a698596b1 Update and rename pylint.yml to pylint_sonarqube.yml
Add RUFF and SonarQube scanning
2025-07-19 18:52:53 -03:00
Gustavo Henrique Miranda 0cbcbd3a8d Update CHANGELOG.md
Revert the markdown format missing from the last update
2025-07-19 17:40:01 -03:00
Gustavo Henrique Miranda a1e718c1fc Update CHANGELOG.md
Added info for the next alpha version 0.0.4
2025-07-19 17:34:05 -03:00
Gustavo Henrique Miranda 8919e158bc Merge pull request #60 from gmbrax/fix/Importing-duplicate-images
Fix the bug that collides the hash if identical images
2025-07-19 03:23:54 -03:00
Gustavo Henrique Santos Souza de Miranda 9e4b08234f Fix the bug that collides the hash if identical images 2025-07-19 00:16:29 -03:00
Gustavo Henrique Miranda 44ff390695 Merge pull request #59 from gmbrax/fix/remove-stale-hashing-methods
Refactor the edit and add photo modal and remove the hashing methods out of the modals
2025-07-18 02:41:29 -03:00
Gustavo Henrique Santos Souza de Miranda ca53ac7778 Remove the unused import and unused variable 2025-07-18 02:37:07 -03:00
Gustavo Henrique Santos Souza de Miranda 0567495ff9 Refactor the edit and add photo modal and remove the hashing methods out of the modals 2025-07-18 02:27:16 -03:00
Gustavo Henrique Miranda 18ff157eb7 Merge pull request #58 from gmbrax/fix/edit-photo-not-working
Fix the photo edit to properly pass the original photo hash that was missing
2025-07-18 01:39:13 -03:00
Gustavo Henrique Santos Souza de Miranda a9d39f415a Fix the photo edit to properly pass the original photo hash that was missing 2025-07-18 00:03:13 -03:00
Gustavo Henrique Miranda b14d775d87 Merge pull request #56 from gmbrax/fix/sidebar-layout
Improve the CSS and the code for the photo sidebar
2025-07-17 21:43:31 -03:00
Gustavo Henrique Santos Souza de Miranda 1d08f4e40d Change the CSS to better distribute the elements of the sidebar 2025-07-17 21:39:05 -03:00
Gustavo Henrique Santos Souza de Miranda 165bc10a63 Change the CSS to fit the text better and also limited the width of the sidebar 2025-07-17 20:48:59 -03:00
Gustavo Henrique Santos Souza de Miranda 71d5491233 Improve the CSS and the code for the photo sidebar 2025-07-17 20:32:54 -03:00
Gustavo Henrique Miranda a59af26c9d Merge pull request #55 from gmbrax/feat/use-a-toml-for-config
Feat/use a toml for config
2025-07-16 22:50:30 -03:00
Gustavo Henrique Santos Souza de Miranda 23ef60b55a Improve exception handling, remove debug print statements 2025-07-16 18:28:42 -03:00
Gustavo Henrique Mirandaandcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> 4436d5e081 Update src/pilgrim/utils/directory_manager.py
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-07-16 18:12:32 -03:00
Gustavo Henrique Santos Souza de Miranda a13c56a3a3 Fix the NewDiaryModal not accessing the toml file properly to set the auto_open flag 2025-07-16 17:24:45 -03:00
Gustavo Henrique Santos Souza de Miranda 967c7e4ce6 Fix the Database no being put on the right path according with the config.toml 2025-07-16 16:39:31 -03:00
Gustavo Henrique Santos Souza de Miranda bdaa37e355 Add the database file path now is read by the configuration file 2025-07-16 16:01:34 -03:00
Gustavo Henrique Santos Souza de Miranda a540347127 Add Tomli_w library to pyproject.toml 2025-07-16 15:31:19 -03:00
Gustavo Henrique Santos Souza de Miranda fe35cb93bd Move get_database_path from database.py to directory_manager.py 2025-07-16 15:30:47 -03:00
Gustavo Henrique Santos Souza de Miranda 3843be6e13 Add the tomli library and create the config_manager.py to manage the configuration file and also add the dependency injection on the ui.py via the Application class 2025-07-16 02:55:52 -03:00
Gustavo Henrique Miranda 0adb230dd7 Merge pull request #52 from gmbrax/fix/move-create-diary-to-modal
Change to call the method in the super class on the _on_screen_resume  method
2025-07-16 01:30:35 -03:00
Gustavo Henrique Santos Souza de Miranda 167c2d8928 Change to call the method in the super class on the _on_screen_resume method 2025-07-16 00:50:07 -03:00
Gustavo Henrique Miranda 5b0f894c88 Merge pull request #50 from gmbrax/fix/bump-version
Bump the version to 0.0.4 on about_screen.py and pyproject.toml
2025-07-16 00:42:42 -03:00
Gustavo Henrique Santos Souza de Miranda 2b851fc7c7 Add the use of importlib.metadata.version to keep the version string in sync with the pyproject.toml 2025-07-16 00:39:28 -03:00
Gustavo Henrique Santos Souza de Miranda a3123fe322 Bump the version to 0.0.4 on about_screen.py and pyproject.toml 2025-07-16 00:25:37 -03:00
26 changed files with 472 additions and 269 deletions
-24
View File
@@ -1,24 +0,0 @@
name: Pylint
on: [push]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
pip install pylint
- name: Analysing the code with pylint
run: |
pylint --disable=C0114,C0115,C0116 --exit-zero $(git ls-files '*.py')
+63
View File
@@ -0,0 +1,63 @@
name: Pylint and SonarCloud
on:
push:
branches: [ main, master, staging ]
pull_request:
branches: [ main, master, staging ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10"]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Necessário para SonarCloud
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
pip install pylint ruff coverage pytest
- name: Analysing the code with pylint (console output)
run: |
pylint --disable=C0114,C0115,C0116 --exit-zero $(git ls-files '*.py') || true
- name: Generate Pylint report for SonarCloud
run: |
pylint --disable=C0114,C0115,C0116 --output-format=json --exit-zero $(git ls-files '*.py') > pylint-report.json || true
- name: Run Ruff
run: |
ruff check --output-format=json . > ruff-report.json || true
- name: Run tests with coverage (if you have tests)
run: |
if [ -d "tests" ] || [ -f "test_*.py" ] || [ -f "*_test.py" ]; then
coverage run -m pytest || true
coverage xml || true
else
echo "No tests found, skipping coverage"
fi
- name: SonarCloud Scan
uses: SonarSource/sonarqube-scan-action@v5.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with:
args: >
-Dsonar.projectKey=gmbrax_Pilgrim
-Dsonar.organization=gmbrax
-Dsonar.python.pylint.reportPaths=pylint-report.json
-Dsonar.python.ruff.reportPaths=ruff-report.json
-Dsonar.python.coverage.reportPaths=coverage.xml
+47 -30
View File
@@ -1,51 +1,68 @@
# Changelog # Changelog
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## Planned
* Installation Method 1 (repository compilation)
* Organization of trips by date, location, or theme
* Enhanced photo management features
* Search functionality
* Export features
* Testing implementation
### Planned ## [0.0.4] - 2025-07-19
- Installation Method 1 (repository compilation) ### Added
- Organization of trips by date, location, or theme * Support for creating new diaries asynchronously, with an option to automatically open the newly created diary
- Enhanced photo management features * Unified "Enter" key support for saving or creating diaries across relevant modals
- Search functionality * Automatic diary list refresh when returning to the diary screen
- Export features * Application configuration management with a new centralized config system
- Testing implementation * Database location and initialization now configurable via the new config manager
* Automatic migration of database file to the configuration directory
* Display of database URL on application startup for transparency
* Duplicate photo detection before photo creation to prevent redundant entries
* Photo hash indexing to improve photo lookup performance
### Changed
* Enhanced feedback and validation when editing or creating diary names
* Streamlined and unified save logic for diary modals, reducing duplicated behavior
* About screen now displays the actual installed application version dynamically
* Sidebar and photo-related UI text updated to remove emoji icons for a cleaner appearance
* Sidebar layout and scrolling behavior improved for better usability
* Photo hash generation now relies on existing service-provided hashes instead of local computation
### Improved
* Enhanced feedback and validation when editing or creating diary names
* Streamlined and unified save logic for diary modals, reducing duplicated behavior
* Sidebar layout and scrolling behavior for better usability
## [0.0.3] - 2025-07-07 ## [0.0.3] - 2025-07-07
### Changed ### Changed
- Removed the dependency on textual-dev from pyproject.toml * Removed the dependency on textual-dev from pyproject.toml
## [0.0.2] - 2025-07-07 ## [0.0.2] - 2025-07-07
### Changed ### Changed
- Changed the license in pyproject.toml to BSD * Changed the license in pyproject.toml to BSD
## [0.0.1] - 2025-07-06 ## [0.0.1] - 2025-07-06
### Added ### Added
* Initial alpha release of Pilgrim travel diary application
- Initial alpha release of Pilgrim travel diary application * Create and edit travel diaries
- Create and edit travel diaries * Create and edit diary entries
- Create and edit diary entries * Photo ingestion system
- Photo ingestion system * Photo addition and reference via sidebar
- Photo addition and reference via sidebar * Text User Interface (TUI) built with Textual framework
- Text User Interface (TUI) built with Textual framework * Pre-compiled binary installation method (Method 2)
- Pre-compiled binary installation method (Method 2) * Support for Linux operating systems
- Support for Linux operating systems * Basic project documentation (README)
- Basic project documentation (README)
### Known Issues ### Known Issues
* Installation Method 1 not yet implemented
- Installation Method 1 not yet implemented * No testing suite implemented yet
- No testing suite implemented yet * Some features may be unstable in an alpha version
- Some features may be unstable in an alpha version
[Unreleased]: https://github.com/username/pilgrim/compare/v0.0.1...HEAD
[0.0.1]: https://github.com/username/pilgrim/releases/tag/v0.0.1
+23 -25
View File
@@ -1,33 +1,31 @@
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[project]
[project] name = "pilgrim"
name = "Pilgrim" version = "0.0.4"
version = "0.0.3" authors = [
authors = [
{ name="Gustavo Henrique Santos Souza de Miranda", email="gustavohssmiranda@gmail.com" } { name="Gustavo Henrique Santos Souza de Miranda", email="gustavohssmiranda@gmail.com" }
] ]
description = "Pilgrim's Travel Log" description = "Pilgrim's Travel Log"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
classifiers = [ classifiers = [
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"License :: OSI Approved :: BSD License", "License :: OSI Approved :: BSD License",
"Operating System :: OS Independent", "Operating System :: OS Independent",
] ]
dependencies = [ dependencies = [
"sqlalchemy", "sqlalchemy",
"textual", "textual",
"tomli",
"tomli_w"
]
] [project.urls]
[template.plugins.default] Homepage = "https://github.com/gmbrax/Pilgrim/"
src-layout = true Issues = "https://github.com/gmbrax/Pilgrim/issues"
[project.urls]
Homepage = "https://github.com/gmbrax/Pilgrim/" [project.scripts]
Issues = "https://github.com/gmbrax/Pilgrim/issues" pilgrim = "pilgrim.command:main"
[tool.hatch.build.targets.wheel]
packages = ["src/pilgrim"]
[project.scripts]
pilgrim = "pilgrim:main"
+6 -4
View File
@@ -1,19 +1,21 @@
from pilgrim.database import Database from pilgrim.database import Database
from pilgrim.service.servicemanager import ServiceManager from pilgrim.service.servicemanager import ServiceManager
from pilgrim.ui.ui import UIApp from pilgrim.ui.ui import UIApp
from pilgrim.utils import DirectoryManager from pilgrim.utils import ConfigManager
class Application: class Application:
def __init__(self): def __init__(self):
self.config_dir = DirectoryManager.get_config_directory() self.config_manager = ConfigManager()
self.database = Database() self.config_manager.read_config() # Chamar antes de criar o Database
self.database = Database(self.config_manager)
session = self.database.session() session = self.database.session()
session_manager = ServiceManager() session_manager = ServiceManager()
session_manager.set_session(session) session_manager.set_session(session)
self.ui = UIApp(session_manager) self.ui = UIApp(session_manager, self.config_manager)
def run(self): def run(self):
print(f"URL do banco: {self.config_manager.database_url}")
self.database.create() self.database.create()
self.ui.run() self.ui.run()
+13 -27
View File
@@ -1,42 +1,28 @@
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import declarative_base
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from pathlib import Path from pathlib import Path
import os import os
import shutil import shutil
from pilgrim.utils import ConfigManager
Base = declarative_base() 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: class Database:
def __init__(self):
db_path = get_database_path() def __init__(self, config_manager: ConfigManager):
self.db_path = config_manager.database_url
# Garantir que o diretório existe
db_dir = os.path.dirname(self.db_path)
if not os.path.exists(db_dir):
os.makedirs(db_dir, exist_ok=True)
self.engine = create_engine( self.engine = create_engine(
f"sqlite:///{db_path}", f"sqlite:///{self.db_path}",
echo=False, echo=False,
connect_args={"check_same_thread": False}, connect_args={"check_same_thread": False},
) )
+2 -1
View File
@@ -4,7 +4,8 @@ from pilgrim.models.photo_in_entry import photo_entry_association
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime from sqlalchemy import Column, Integer, String, ForeignKey, DateTime
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from ..database import Base from pilgrim.database import Base
class Entry(Base): class Entry(Base):
+6 -1
View File
@@ -4,9 +4,11 @@ from pathlib import Path
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime from sqlalchemy import Column, Integer, String, ForeignKey, DateTime
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql.schema import Index
from pilgrim.models.photo_in_entry import photo_entry_association from pilgrim.models.photo_in_entry import photo_entry_association
from ..database import Base from pilgrim.database import Base
class Photo(Base): class Photo(Base):
@@ -24,6 +26,9 @@ class Photo(Base):
) )
fk_travel_diary_id = Column(Integer, ForeignKey("travel_diaries.id"),nullable=False) fk_travel_diary_id = Column(Integer, ForeignKey("travel_diaries.id"),nullable=False)
__table_args__ = (
Index('idx_photo_hash_diary', 'hash', 'fk_travel_diary_id'),
)
def __init__(self, filepath, name, photo_hash, addition_date=None, caption=None, entries=None, fk_travel_diary_id=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) super().__init__(**kw)
+1 -1
View File
@@ -1,6 +1,6 @@
from sqlalchemy import Table, Column, Integer, ForeignKey from sqlalchemy import Table, Column, Integer, ForeignKey
from ..database import Base from pilgrim.database import Base
photo_entry_association = Table('photo_entry_association', Base.metadata, photo_entry_association = Table('photo_entry_association', Base.metadata,
Column('id', Integer, primary_key=True, autoincrement=True), Column('id', Integer, primary_key=True, autoincrement=True),
+3 -2
View File
@@ -3,10 +3,11 @@ from typing import Any
from sqlalchemy import Column, Integer, String, UniqueConstraint from sqlalchemy import Column, Integer, String, UniqueConstraint
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from .. import database from pilgrim.database import Base
class TravelDiary(database.Base):
class TravelDiary(Base):
__tablename__ = "travel_diaries" __tablename__ = "travel_diaries"
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
name = Column(String, nullable=False) name = Column(String, nullable=False)
+3 -3
View File
@@ -1,9 +1,9 @@
from datetime import datetime from datetime import datetime
from typing import List from typing import List
from ..models.entry import Entry from pilgrim.models.entry import Entry
from ..models.travel_diary import TravelDiary from pilgrim.models.travel_diary import TravelDiary
from ..models.photo import Photo # ✨ Importe o modelo Photo from pilgrim.models.photo import Photo # ✨ Importe o modelo Photo
class EntryService: class EntryService:
+12 -5
View File
@@ -14,8 +14,9 @@ class PhotoService:
def __init__(self, session): def __init__(self, session):
self.session = session self.session = session
def _hash_file(self, filepath: Path) -> str: @staticmethod
"""Calculate hash of a file using SHA3-384.""" def hash_file(filepath: Path) -> str:
"""Calculate the hash of a file using SHA3-384."""
hash_func = hashlib.new('sha3_384') hash_func = hashlib.new('sha3_384')
with open(filepath, 'rb') as f: with open(filepath, 'rb') as f:
while chunk := f.read(8192): while chunk := f.read(8192):
@@ -64,10 +65,18 @@ class PhotoService:
return dest_path return dest_path
def check_photo_by_hash(self, photohash:str, traveldiaryid:int):
photo = (self.session.query(Photo).filter(Photo.photo_hash == photohash,Photo.fk_travel_diary_id == traveldiaryid)
.first())
return photo
def create(self, filepath: Path, name: str, travel_diary_id: int, caption=None, addition_date=None) -> Photo | None: def create(self, filepath: Path, name: str, travel_diary_id: int, caption=None, addition_date=None) -> Photo | None:
travel_diary = self.session.query(TravelDiary).filter(TravelDiary.id == travel_diary_id).first() travel_diary = self.session.query(TravelDiary).filter(TravelDiary.id == travel_diary_id).first()
if not travel_diary: if not travel_diary:
return None return None
photo_hash = self.hash_file(filepath)
if self.check_photo_by_hash(photo_hash, travel_diary_id):
return None
# Copy photo to diary's images directory # Copy photo to diary's images directory
copied_path = self._copy_photo_to_diary(filepath, travel_diary) copied_path = self._copy_photo_to_diary(filepath, travel_diary)
@@ -79,8 +88,6 @@ class PhotoService:
except ValueError: except ValueError:
addition_date = None addition_date = None
# Calculate hash from the copied file
photo_hash = self._hash_file(copied_path)
new_photo = Photo( new_photo = Photo(
filepath=str(copied_path), # Store the path to the copied file filepath=str(copied_path), # Store the path to the copied file
@@ -118,7 +125,7 @@ class PhotoService:
old_path.unlink() old_path.unlink()
original.filepath = str(new_path) original.filepath = str(new_path)
# Update hash based on the new copied file # Update hash based on the new copied file
original.photo_hash = self._hash_file(new_path) original.photo_hash = self.hash_file(new_path)
original.name = photo_dst.name original.name = photo_dst.name
original.addition_date = photo_dst.addition_date original.addition_date = photo_dst.addition_date
+1 -1
View File
@@ -6,7 +6,7 @@ from pathlib import Path
from pilgrim.utils import DirectoryManager from pilgrim.utils import DirectoryManager
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from ..models.travel_diary import TravelDiary from pilgrim.models.travel_diary import TravelDiary
class TravelDiaryService: class TravelDiaryService:
+2 -2
View File
@@ -3,7 +3,7 @@ from textual.binding import Binding
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Header, Footer, Button, Label, TextArea from textual.widgets import Header, Footer, Button, Label, TextArea
from textual.containers import Container from textual.containers import Container
from importlib.metadata import version
class AboutScreen(Screen[bool]): class AboutScreen(Screen[bool]):
"""Screen to display application information.""" """Screen to display application information."""
@@ -20,7 +20,7 @@ class AboutScreen(Screen[bool]):
self.app_title = Label("Pilgrim", id="AboutScreen_AboutTitle",classes="AboutScreen_AboutTitle") self.app_title = Label("Pilgrim", id="AboutScreen_AboutTitle",classes="AboutScreen_AboutTitle")
self.content = Label("A TUI Based Travel Diary Application", id="AboutScreen_AboutContent", self.content = Label("A TUI Based Travel Diary Application", id="AboutScreen_AboutContent",
classes="AboutScreen_AboutContent") classes="AboutScreen_AboutContent")
self.version = Label("Version: 0.0.1", id="AboutScreen_AboutVersion", self.version = Label(f"Version: {version('Pilgrim')}", id="AboutScreen_AboutVersion",
classes="AboutScreen_AboutVersion") classes="AboutScreen_AboutVersion")
self.developer = Label("Developed By: Gustavo Henrique Miranda ", id="AboutScreen_AboutAuthor") self.developer = Label("Developed By: Gustavo Henrique Miranda ", id="AboutScreen_AboutAuthor")
self.contact = Label("git.gustavomiranda.xyz", id="AboutScreen_AboutContact", self.contact = Label("git.gustavomiranda.xyz", id="AboutScreen_AboutContact",
+4 -16
View File
@@ -203,17 +203,11 @@ class DiaryListScreen(Screen):
self.action_open_diary() self.action_open_diary()
def action_new_diary(self): def action_new_diary(self):
""" """Action to create new diary"""
Opens the modal dialog to create a new diary and registers a callback for submission.
"""
self.app.push_screen(NewDiaryModal(),self._on_new_diary_submitted) self.app.push_screen(NewDiaryModal(),self._on_new_diary_submitted)
def _on_new_diary_submitted(self, result): def _on_new_diary_submitted(self, result):
""" """Callback after diary creation"""
Handles the callback after a new diary is submitted.
If a diary was created (non-empty result), notifies the user and refreshes the diary list; otherwise, notifies that creation was canceled.
"""
if result: # Se result não é string vazia, o diário foi criado if result: # Se result não é string vazia, o diário foi criado
self.notify(f"Returning to diary list...") self.notify(f"Returning to diary list...")
# Atualiza a lista de diários # Atualiza a lista de diários
@@ -222,17 +216,11 @@ class DiaryListScreen(Screen):
self.notify(f"Creation canceled...") self.notify(f"Creation canceled...")
def _on_screen_resume(self) -> None: def _on_screen_resume(self) -> None:
""" super()._on_screen_resume()
Refreshes the diary list when the screen resumes.
"""
self.refresh_diaries() self.refresh_diaries()
def action_edit_selected_diary(self): def action_edit_selected_diary(self):
""" """Action to edit selected diary"""
Opens the edit dialog for the currently selected diary.
If a diary is selected, displays the edit modal and registers a callback for when the edit is submitted. Notifies the user if no diary is selected.
"""
if self.selected_diary_index is not None: if self.selected_diary_index is not None:
diary_id = self.diary_id_map.get(self.selected_diary_index) diary_id = self.diary_id_map.get(self.selected_diary_index)
if diary_id: if diary_id:
@@ -12,9 +12,6 @@ class EditDiaryModal(ModalScreen[tuple[int,str]]):
] ]
def __init__(self, diary_id: int): def __init__(self, diary_id: int):
"""
Initialize the EditDiaryModal with the specified diary ID, pre-filling the input field with the current diary name.
"""
super().__init__() super().__init__()
self.diary_id = diary_id 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.current_diary_name = self.app.service_manager.get_travel_diary_service().read_by_id(self.diary_id).name
@@ -35,29 +32,16 @@ class EditDiaryModal(ModalScreen[tuple[int,str]]):
self.name_input.cursor_position = len(self.name_input.value) self.name_input.cursor_position = len(self.name_input.value)
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
"""
Handle button press events in the modal.
Triggers the save action if the "Save" button is pressed, or cancels and dismisses the modal if the "Cancel" button is pressed.
"""
if event.button.id == "save_diary_button": if event.button.id == "save_diary_button":
self.action_edit_diary() self.action_edit_diary()
elif event.button.id == "cancel_button": elif event.button.id == "cancel_button":
self.dismiss(None) self.dismiss(None)
def on_key(self, event): def on_key(self, event):
"""
Handles the Enter key press event by triggering the diary save action and preventing the default behavior.
"""
if event.key == "enter": if event.key == "enter":
self.action_edit_diary() self.action_edit_diary()
event.prevent_default() event.prevent_default()
def action_edit_diary(self) -> None: def action_edit_diary(self) -> None:
"""
Attempts to save the edited diary name, validating input and providing user feedback.
If the new name is non-empty and different from the current name, dismisses the modal and returns the diary ID and new name. If the name is unchanged, notifies the user and dismisses without changes. If the input is empty, notifies the user and refocuses the input field.
"""
new_diary_name = self.name_input.value.strip() new_diary_name = self.name_input.value.strip()
if new_diary_name and new_diary_name != self.current_diary_name: if new_diary_name and new_diary_name != self.current_diary_name:
self.dismiss((self.diary_id, new_diary_name)) self.dismiss((self.diary_id, new_diary_name))
@@ -69,14 +53,8 @@ class EditDiaryModal(ModalScreen[tuple[int,str]]):
self.name_input.focus() self.name_input.focus()
def on_input_submitted(self, event: Input.Submitted) -> None: def on_input_submitted(self, event: Input.Submitted) -> None:
"""
Handles submission of the diary name input field by triggering the save action if the relevant input is submitted.
"""
if event.input.id == "edit_diary_name_input": if event.input.id == "edit_diary_name_input":
self.action_edit_diary() self.action_edit_diary()
def action_cancel(self) -> None: def action_cancel(self) -> None:
"""
Dismisses the modal without saving changes or returning any data.
"""
self.dismiss(None) self.dismiss(None)
+15 -18
View File
@@ -87,7 +87,7 @@ class EditEntryScreen(Screen):
self.text_entry = TextArea(id="text_entry", classes="EditEntryScreen-text-entry") self.text_entry = TextArea(id="text_entry", classes="EditEntryScreen-text-entry")
# Sidebar widgets # Sidebar widgets
self.sidebar_title = Static("📸 Photos", classes="EditEntryScreen-sidebar-title") self.sidebar_title = Static("Photos", classes="EditEntryScreen-sidebar-title")
self.photo_list = OptionList(id="photo_list", classes="EditEntryScreen-sidebar-photo-list") self.photo_list = OptionList(id="photo_list", classes="EditEntryScreen-sidebar-photo-list")
self.photo_info = Static("", classes="EditEntryScreen-sidebar-photo-info") self.photo_info = Static("", classes="EditEntryScreen-sidebar-photo-info")
self.help_text = Static("", classes="EditEntryScreen-sidebar-help") self.help_text = Static("", classes="EditEntryScreen-sidebar-help")
@@ -296,30 +296,28 @@ class EditEntryScreen(Screen):
if not self.cached_photos: if not self.cached_photos:
self.photo_info.update("No photos found for this diary") self.photo_info.update("No photos found for this diary")
self.help_text.update("📸 No photos available\n\nUse Photo Manager to add photos") self.help_text.update("No photos available\n\nUse Photo Manager to add photos")
return return
# Add photos to the list with hash # Add photos to the list with hash
for photo in self.cached_photos: for photo in self.cached_photos:
# Show name and hash in the list # Show name and hash in the list
photo_hash = str(photo.photo_hash)[:8] photo_hash = str(photo.photo_hash)[:8]
self.photo_list.add_option(f"📷 {photo.name} \\[{photo_hash}\]") self.photo_list.add_option(f"{photo.name} \\[{photo_hash}\]")
self.photo_info.update(f"📸 {len(self.cached_photos)} photos in diary") self.photo_info.update(f"{len(self.cached_photos)} photos in diary")
# Updated help a text with hash information # Updated help a text with hash information
help_text = ( help_text = (
"[b]⌨️ Sidebar Shortcuts[/b]\n" "[b]Sidebar Shortcuts[/b]\n"
"[b][green]i[/green][/b]: Insert photo into entry\n" "[b][green]i[/green][/b]: Insert photo into entry\n"
"[b][green]n[/green][/b]: Add new photo\n" "[b][green]n[/green][/b]: Add new photo\n"
"[b][green]d[/green][/b]: Delete selected photo\n" "[b][green]d[/green][/b]: Delete selected photo\n"
"[b][green]e[/green][/b]: Edit selected photo\n" "[b][green]e[/green][/b]: Edit selected photo\n"
"[b][yellow]Tab[/yellow][/b]: Back to editor\n" "[b][yellow]Tab[/yellow][/b]: Back to editor\n"
"[b][yellow]F8[/yellow][/b]: Show/hide sidebar\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" "[b]📝 Photo References[/b]\n"
"Use: \\[\\[photo:name:hash\\]\\]\n" "\\[\\[photo::hash\\]\\]"
"Or: \\[\\[photo::hash\\]\\]"
) )
self.help_text.update(help_text) self.help_text.update(help_text)
except Exception as e: except Exception as e:
@@ -429,9 +427,7 @@ class EditEntryScreen(Screen):
photo_details += f"🔗 {photo_hash}\n" photo_details += f"🔗 {photo_hash}\n"
photo_details += f"📅 {selected_photo.addition_date}\n" photo_details += f"📅 {selected_photo.addition_date}\n"
photo_details += f"💬 {selected_photo.caption or 'No caption'}\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"[b]Reference formats:[/b]\n"
photo_details += f"\\[\\[photo:{selected_photo.name}:{photo_hash}\\]\\]\n"
photo_details += f"\\[\\[photo::{photo_hash}\\]\\]" photo_details += f"\\[\\[photo::{photo_hash}\\]\\]"
self.photo_info.update(photo_details) self.photo_info.update(photo_details)
@@ -615,7 +611,8 @@ class EditEntryScreen(Screen):
addition_date=original_photo.addition_date, addition_date=original_photo.addition_date,
caption=photo_data["caption"], caption=photo_data["caption"],
entries=original_photo.entries if original_photo.entries is not None else [], entries=original_photo.entries if original_photo.entries is not None else [],
id=original_photo.id id=original_photo.id,
photo_hash=original_photo.photo_hash,
) )
result = photo_service.update(original_photo, updated_photo) result = photo_service.update(original_photo, updated_photo)
@@ -742,15 +739,15 @@ class EditEntryScreen(Screen):
self.notify(f"Selected photo: {selected_photo.name} \\[{photo_hash}\\]") self.notify(f"Selected photo: {selected_photo.name} \\[{photo_hash}\\]")
# Update photo info with details including hash # Update photo info with details including hash
photo_details = f"📷 {selected_photo.name}\n" photo_details = f"Name: {selected_photo.name}\n"
photo_details += f"🔗 {photo_hash}\n" photo_details += f"Hash: {photo_hash}\n"
photo_details += f"📅 {selected_photo.addition_date}\n" photo_details += f"Date: {selected_photo.addition_date}\n"
if selected_photo.caption: if selected_photo.caption:
photo_details += f"💬 {selected_photo.caption}\n" photo_details += f"Caption: {selected_photo.caption}\n"
photo_details += f"📁 {selected_photo.filepath}\n\n" else:
photo_details += f"Caption: No Caption\n"
photo_details += f"[b]Reference formats:[/b]\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}]]"
photo_details += f"\\[\\[photo::{photo_hash}\\]\\]"
self.photo_info.update(photo_details) self.photo_info.update(photo_details)
@@ -15,12 +15,6 @@ class AddPhotoModal(Screen):
self.result = None self.result = None
self.created_photo = 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: def compose(self) -> ComposeResult:
yield Container( yield Container(
@@ -69,10 +63,16 @@ class AddPhotoModal(Screen):
async def _async_create_photo(self, photo_data: dict): async def _async_create_photo(self, photo_data: dict):
"""Creates a new photo asynchronously using PhotoService""" """Creates a new photo asynchronously using PhotoService"""
try: try:
service_manager = self.app.service_manager service_manager = self.app.service_manager
photo_service = service_manager.get_photo_service() photo_service = service_manager.get_photo_service()
if photo_service.check_photo_by_hash(photo_service.hash_file(photo_data["filepath"]),self.diary_id):
self.notify("Photo already exists in database", severity="error")
return
new_photo = photo_service.create( new_photo = photo_service.create(
filepath=Path(photo_data["filepath"]), filepath=Path(photo_data["filepath"]),
name=photo_data["name"], name=photo_data["name"],
@@ -82,13 +82,9 @@ class AddPhotoModal(Screen):
if new_photo: if new_photo:
self.created_photo = 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}\\]\\]",
self.notify(f"Photo '{new_photo.name}' added successfully!\nHash: {new_photo.photo_hash[:8]}\nReference: \\[\\[photo:{new_photo.name}:{new_photo.photo_hash[:8]}\\]\\]",
severity="information", timeout=5) severity="information", timeout=5)
# Return the created photo data to the calling screen # Return the created photo data to the calling screen
@@ -97,7 +93,7 @@ class AddPhotoModal(Screen):
"name": photo_data["name"], "name": photo_data["name"],
"caption": photo_data["caption"], "caption": photo_data["caption"],
"photo_id": new_photo.id, "photo_id": new_photo.id,
"hash": photo_hash "hash": new_photo.photo_hash
} }
self.dismiss(self.result) self.dismiss(self.result)
else: else:
@@ -3,7 +3,7 @@ from textual.screen import Screen
from textual.widgets import Static, Input, Button from textual.widgets import Static, Input, Button
from textual.containers import Container, Horizontal from textual.containers import Container, Horizontal
from pilgrim.models.photo import Photo from pilgrim.models.photo import Photo
import hashlib
class EditPhotoModal(Screen): class EditPhotoModal(Screen):
"""Modal for editing an existing photo (name and caption only)""" """Modal for editing an existing photo (name and caption only)"""
@@ -12,15 +12,11 @@ class EditPhotoModal(Screen):
self.photo = photo self.photo = photo
self.result = None 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: def compose(self) -> ComposeResult:
# Generate hash for this photo # Generate hash for this photo
photo_hash = self._generate_photo_hash(self.photo)
yield Container( yield Container(
Static("✏️ Edit Photo", classes="EditPhotoModal-Title"), Static("✏️ Edit Photo", classes="EditPhotoModal-Title"),
@@ -45,10 +41,9 @@ class EditPhotoModal(Screen):
id="caption-input", id="caption-input",
classes="EditPhotoModal-Input" classes="EditPhotoModal-Input"
), ),
Static(f"🔗 Photo Hash: {photo_hash}", classes="EditPhotoModal-Hash"), Static(f"🔗 Photo Hash: {self.photo.photo_hash[:8]}", classes="EditPhotoModal-Hash"),
Static("Reference formats:", classes="EditPhotoModal-Label"), Static("Reference formats:", classes="EditPhotoModal-Label"),
Static(f"\\[\\[photo:{self.photo.name}:{photo_hash}\\]\\]", classes="EditPhotoModal-Reference"), Static(f"\\[\\[photo::{self.photo.photo_hash[:8]}\\]\\]", classes="EditPhotoModal-Reference"),
Static(f"\\[\\[photo::{photo_hash}\\]\\]", classes="EditPhotoModal-Reference"),
Horizontal( Horizontal(
Button("Save Changes", id="save-button", classes="EditPhotoModal-Button"), Button("Save Changes", id="save-button", classes="EditPhotoModal-Button"),
Button("Cancel", id="cancel-button", classes="EditPhotoModal-Button"), Button("Cancel", id="cancel-button", classes="EditPhotoModal-Button"),
+4 -35
View File
@@ -12,25 +12,13 @@ class NewDiaryModal(ModalScreen[str]):
Binding("escape", "cancel", "Cancel"), Binding("escape", "cancel", "Cancel"),
Binding("enter", "create_diary", "Create",priority=True), Binding("enter", "create_diary", "Create",priority=True),
] ]
def __init__(self,autoopen=True): def __init__(self):
"""
Initialize the NewDiaryModal with optional automatic opening of the created diary.
Parameters:
autoopen (bool): If True, the newly created diary will open automatically after creation. Defaults to True.
"""
super().__init__() super().__init__()
self.auto_open = autoopen self.auto_open = self.app.config_manager.auto_open_new_diary
self.name_input = Input(id="NewDiaryModal-NameInput",classes="NewDiaryModal-NameInput") # This ID is fine, it's specific to the input self.name_input = Input(id="NewDiaryModal-NameInput",classes="NewDiaryModal-NameInput") # This ID is fine, it's specific to the input
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""
Constructs and yields the UI layout for the new diary modal dialog.
Returns:
ComposeResult: An iterable of widgets forming the modal, including title, input field, and action buttons.
"""
with Vertical(id="new_diary_dialog",classes="NewDiaryModal-Dialog"): with Vertical(id="new_diary_dialog",classes="NewDiaryModal-Dialog"):
yield Label("Create a new diary", classes="NewDiaryModal-Title") yield Label("Create a new diary", classes="NewDiaryModal-Title")
yield Label("Diary Name:") yield Label("Diary Name:")
@@ -45,28 +33,17 @@ class NewDiaryModal(ModalScreen[str]):
self.name_input.focus() self.name_input.focus()
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
""" """Handles button clicks."""
Handle button press events for the modal.
Triggers diary creation when the "Create" button is pressed, or dismisses the modal when the "Cancel" button is pressed.
"""
if event.button.id == "create_diary_button": if event.button.id == "create_diary_button":
self.action_create_diary() self.action_create_diary()
elif event.button.id == "cancel_button": elif event.button.id == "cancel_button":
self.dismiss() self.dismiss()
def action_cancel(self) -> None: def action_cancel(self) -> None:
""" """Action to cancel the modal."""
Cancels the modal dialog and dismisses it without returning a diary name.
"""
self.dismiss("") self.dismiss("")
def action_create_diary(self) -> None: def action_create_diary(self) -> None:
"""
Validates the diary name input and initiates asynchronous diary creation if valid.
If the input is empty, displays a warning notification and refocuses the input field.
"""
diary_name = self.name_input.value.strip() diary_name = self.name_input.value.strip()
if diary_name: if diary_name:
@@ -77,19 +54,11 @@ class NewDiaryModal(ModalScreen[str]):
self.name_input.focus() self.name_input.focus()
def on_input_submitted(self, event: Input.Submitted) -> None: def on_input_submitted(self, event: Input.Submitted) -> None:
"""
Handles submission of the diary name input field and initiates diary creation if triggered from the correct input.
"""
if event.input.id == "NewDiaryModal-NameInput": if event.input.id == "NewDiaryModal-NameInput":
self.action_create_diary() self.action_create_diary()
async def _async_create_diary(self, name: str): async def _async_create_diary(self, name: str):
"""
Asynchronously creates a new diary entry with the given name and handles post-creation actions.
If creation succeeds, dismisses the modal with the diary name, optionally opens the diary for editing, and shows a success notification. If creation fails or an exception occurs, displays an error notification.
"""
try: try:
service = self.app.service_manager.get_travel_diary_service() service = self.app.service_manager.get_travel_diary_service()
created_diary = await service.async_create(name) created_diary = await service.async_create(name)
+18 -21
View File
@@ -389,22 +389,10 @@ Screen.-modal {
width: 1fr; width: 1fr;
} }
.EditEntryScreen-sidebar { .EditEntryScreen-sidebar{
width: 40; background: $primary-darken-1;
min-height: 10; width: 45%;
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 { .EditEntryScreen-sidebar-title {
text-align: center; text-align: center;
text-style: bold; text-style: bold;
@@ -417,30 +405,39 @@ Screen.-modal {
.EditEntryScreen-sidebar-content { .EditEntryScreen-sidebar-content {
height: 1fr; height: 1fr;
layout: vertical; layout: vertical;
margin-left: 1;
margin-right: 1;
} }
.EditEntryScreen-sidebar-photo-list { .EditEntryScreen-sidebar-photo-list {
height: 1fr; height: 2fr; /* Pega o espaço restante disponível */
min-height: 5; /* Garante altura mínima para não sumir */
border: solid $accent; border: solid $accent;
margin-bottom: 1; margin-bottom: 1;
overflow-y: auto; /* Adiciona scroll vertical se necessário */
} }
.EditEntryScreen-sidebar-photo-info { .EditEntryScreen-sidebar-photo-info {
height: auto; height: 1fr;
min-height: 3; max-height: 15; /* Limita altura máxima */
border: solid $warning; min-height: 13;
padding: 1; padding: 1;
border: solid $warning;
margin-bottom: 1; margin-bottom: 1;
background: $surface-darken-1; background: $surface-darken-1;
overflow-y: auto; /* Adiciona scroll se exceder max-height */
} }
.EditEntryScreen-sidebar-help { .EditEntryScreen-sidebar-help {
height: auto; height: 1fr;
min-height: 8; max-height: 10; /* Altura máxima menor que info */
min-height: 8; /* Altura mínima menor */
border: solid $success; border: solid $success;
padding: 1; padding: 1;
background: $surface-darken-1; background: $surface-darken-1;
text-style: italic; text-style: italic;
overflow-y: auto; /* Adiciona scroll se exceder max-height */
} }
/* Photo Modal Styles */ /* Photo Modal Styles */
+3 -1
View File
@@ -9,6 +9,7 @@ from pilgrim.service.servicemanager import ServiceManager
from pilgrim.ui.screens.about_screen import AboutScreen from pilgrim.ui.screens.about_screen import AboutScreen
from pilgrim.ui.screens.diary_list_screen import DiaryListScreen from pilgrim.ui.screens.diary_list_screen import DiaryListScreen
from pilgrim.ui.screens.edit_entry_screen import EditEntryScreen from pilgrim.ui.screens.edit_entry_screen import EditEntryScreen
from pilgrim.utils import ConfigManager
CSS_FILE_PATH = Path(__file__).parent / "styles" / "pilgrim.css" CSS_FILE_PATH = Path(__file__).parent / "styles" / "pilgrim.css"
@@ -16,9 +17,10 @@ CSS_FILE_PATH = Path(__file__).parent / "styles" / "pilgrim.css"
class UIApp(App): class UIApp(App):
CSS_PATH = CSS_FILE_PATH CSS_PATH = CSS_FILE_PATH
def __init__(self,service_manager: ServiceManager, **kwargs): def __init__(self,service_manager: ServiceManager, config_manager: ConfigManager, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.service_manager = service_manager self.service_manager = service_manager
self.config_manager = config_manager
def on_mount(self) -> None: def on_mount(self) -> None:
+2 -1
View File
@@ -1,3 +1,4 @@
from .directory_manager import DirectoryManager from .directory_manager import DirectoryManager
from .config_manager import ConfigManager
__all__ = ['DirectoryManager'] __all__ = ['DirectoryManager', 'ConfigManager']
+107
View File
@@ -0,0 +1,107 @@
import os.path
from os import PathLike
from threading import Lock
import tomli
import tomli_w
from pilgrim.utils import DirectoryManager
class SingletonMeta(type):
_instances = {}
_lock: Lock = Lock()
def __call__(cls, *args, **kwargs):
with cls._lock:
if cls not in cls._instances:
instance = super().__call__(*args, **kwargs)
cls._instances[cls] = instance
return cls._instances[cls]
class ConfigManager(metaclass=SingletonMeta):
def __init__(self):
self.database_url = None
self.database_type = None
self.auto_open_diary = None
self.auto_open_new_diary = None
self.config_dir = DirectoryManager.get_config_directory()
self.__data = None
def read_config(self):
if os.path.exists(f"{DirectoryManager.get_config_directory()}/config.toml"):
try:
with open(f"{DirectoryManager.get_config_directory()}/config.toml", "rb") as f:
data = tomli.load(f)
except tomli.TOMLDecodeError as e:
raise ValueError(f"Invalid TOML configuration: {e}")
except Exception as e:
raise RuntimeError(f"Error reading configuration: {e}")
self.__data = data
self.database_url = self.__data["database"]["url"]
self.database_type = self.__data["database"]["type"]
if self.__data["settings"]["diary"]["auto_open_diary_on_startup"] == "":
self.auto_open_diary = None
self.auto_open_new_diary = self.__data["settings"]["diary"]["auto_open_on_creation"]
else:
print("Error: config.toml not found.")
self.create_config()
self.read_config()
def create_config(self, config: dict = None):
# Garantir que o diretório de configuração existe
config_dir = DirectoryManager.get_config_directory()
if not os.path.exists(config_dir):
os.makedirs(config_dir, exist_ok=True)
default = {
"database": {
"url": f"{config_dir}/database.db",
"type": "sqlite"
},
"settings": {
"diary": {
"auto_open_diary_on_startup": "",
"auto_open_on_creation": False
}
}
}
if config is None:
config = default
try:
with open(f"{config_dir}/config.toml", "wb") as f:
tomli_w.dump(config, f)
except Exception as e:
raise RuntimeError(f"Error creating configuration: {e}")
def save_config(self):
if self.__data is None:
self.read_config()
if self.__data is None:
raise RuntimeError("Error reading configuration.")
self.__data["database"]["url"] = self.database_url
self.__data["database"]["type"] = self.database_type
self.__data["settings"]["diary"]["auto_open_diary_on_startup"] = self.auto_open_diary or ""
self.__data["settings"]["diary"]["auto_open_on_creation"] = self.auto_open_new_diary
try:
self.create_config(self.__data)
except Exception as e:
raise RuntimeError(f"Error saving configuration: {e}")
def set_config_dir(self, value):
self.config_dir = value
def set_database_url(self, value: str):
self.database_url = value
def set_auto_open_diary(self, value: str):
self.auto_open_diary = value
def set_auto_open_new_diary(self, value: bool):
self.auto_open_new_diary = value
+24
View File
@@ -1,4 +1,5 @@
import os import os
import shutil
from pathlib import Path from pathlib import Path
@@ -37,3 +38,26 @@ class DirectoryManager:
def get_diary_images_directory(directory_name: str) -> Path: def get_diary_images_directory(directory_name: str) -> Path:
"""Returns the images directory path for a specific diary.""" """Returns the images directory path for a specific diary."""
return DirectoryManager.get_diary_data_directory(directory_name) / "images" return DirectoryManager.get_diary_data_directory(directory_name) / "images"
@staticmethod
def get_database_path() -> Path:
"""
Get the database file path following XDG Base Directory specification.
Creates the directory if it doesn't exist.
"""
pilgrim_dir = DirectoryManager.get_config_directory()
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():
try:
shutil.copy2(current_db, db_path)
# Consider using logging instead of print
print(f"Database migrated from {current_db} to {db_path}")
except (OSError, shutil.Error) as e:
raise RuntimeError(f"Failed to migrate database: {e}")
return db_path
+93
View File
@@ -0,0 +1,93 @@
import pytest
from datetime import datetime
from unittest.mock import Mock
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from pilgrim.database import Base
from pilgrim.models.travel_diary import TravelDiary
from pilgrim.models.entry import Entry
from pilgrim.models.photo import Photo
from pilgrim.service.entry_service import EntryService
@pytest.fixture(scope="function")
def db_session():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
Base.metadata.drop_all(engine)
@pytest.fixture
def populated_db_session(db_session):
travel_diary = TravelDiary(name="My Travel Diary", directory_name="viagem-teste")
db_session.add(travel_diary)
db_session.commit()
return db_session
def test_create_entry_successfully(populated_db_session):
session = populated_db_session
service = EntryService(session)
diary_id = 1 # Sabemos que o ID é 1 por causa da nossa fixture
title = "Primeiro Dia na Praia"
text = "O dia foi ensolarado e o mar estava ótimo."
date = datetime(2025, 7, 20)
photos = [Photo(filepath="/path/to/photo1.jpg",name="Photo 1",photo_hash="hash_12345678",fk_travel_diary_id=diary_id), Photo(filepath="/path/to/photo2.jpg",name="Photo 1",photo_hash="hash_87654321",fk_travel_diary_id=diary_id)]
created_entry = service.create(
travel_diary_id=diary_id,
title=title,
text=text,
date=date,
photos=photos
)
assert created_entry is not None
assert created_entry.id is not None # Garante que foi salvo no BD e tem um ID
assert created_entry.title == title
assert created_entry.text == text
assert len(created_entry.photos) == 2
assert created_entry.photos[0].filepath == "/path/to/photo1.jpg"
entry_in_db = session.query(Entry).filter_by(id=created_entry.id).one()
assert entry_in_db.title == "Primeiro Dia na Praia"
def test_create_entry_fails_when_diary_id_is_invalid(db_session):
session = db_session
service = EntryService(session)
invalid_id = 666
result = service.create(
travel_diary_id=invalid_id,
title="Título de Teste",
text="Texto de Teste",
date=datetime(2025, 7, 20),
photos=[]
)
assert result is None
def test_create_entry_successfully_without_photo(populated_db_session):
session = populated_db_session
service = EntryService(session)
diary_id = 1 # Sabemos que o ID é 1 por causa da nossa fixture
title = "Primeiro Dia na Praia"
text = "O dia foi ensolarado e o mar estava ótimo."
date = datetime(2025, 7, 20)
photos = []
created_entry = service.create(
travel_diary_id=diary_id,
title=title,
text=text,
date=date,
photos=photos
)
assert created_entry is not None
assert created_entry.id is not None # Garante que foi salvo no BD e tem um ID
assert created_entry.title == title
assert created_entry.text == text
assert len(created_entry.photos) == 0
entry_in_db = session.query(Entry).filter_by(id=created_entry.id).one()
assert entry_in_db.title == "Primeiro Dia na Praia"