Compare commits

..
Author SHA1 Message Date
Gustavo Henrique Santos Souza de Miranda 6dc2c01325 Revert "Update README.md"
This reverts commit 60311bc714.
2025-07-12 12:41:20 -03:00
Gustavo Henrique Santos Souza de Miranda 860fee2d19 Revert "Fixed the new entry creation flow by prompting for a title if none is set."
This reverts commit b704a524
2025-07-12 12:33:24 -03:00
Gustavo Henrique Santos Souza de Miranda fce47db0f8 Resolved the conflicts 2025-07-12 12:24:37 -03:00
Gustavo Henrique Santos Souza de Miranda 6ab35e680d Merge branch 'fix/create-a-new-entry-on-open' into development
Changed the behaviour to create a new entry when a diary is open a
2025-07-12 12:14:17 -03:00
Gustavo Henrique Santos Souza de Miranda de0e415ee2 Merge branch 'fix/binding-not-working' into development
Fixed the Footer to work properly with clicking too.k
2025-07-08 01:21:13 -03:00
Gustavo Henrique Santos Souza de Miranda f42d15048c Merge branch 'fix/status-not-updating' into development
Fixed the document status to properly change when the user edit something thus warning the document is not saved.nijthen
2025-07-08 00:44:11 -03:00
Gustavo Henrique Santos Souza de Miranda 40104db405 Merge branch 'feat/added-licenses-from-deps' into development
Added the licenses from the dependences used by the application
2025-07-08 00:36:25 -03:00
Gustavo Henrique Miranda d13750f725 Merge pull request #23 from gmbrax/fix/wrong-database-access
Fix/wrong database access
2025-07-06 03:28:27 -03:00
Gustavo Henrique Miranda 0b9b3c8cf0 Merge pull request #22 from gmbrax/fix/binding-not-working
Merging fix/binding-not-working into development
2025-07-06 03:11:17 -03:00
32 changed files with 296 additions and 1097 deletions
+24
View File
@@ -0,0 +1,24 @@
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
@@ -1,63 +0,0 @@
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
+30 -47
View File
@@ -1,68 +1,51 @@
# Changelog
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.
## Unreleased
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Planned
* Installation Method 1 (repository compilation)
* Organization of trips by date, location, or theme
* Enhanced photo management features
* Search functionality
* Export features
* Testing implementation
## [Unreleased]
## [0.0.4] - 2025-07-19
### Planned
### Added
* Support for creating new diaries asynchronously, with an option to automatically open the newly created diary
* Unified "Enter" key support for saving or creating diaries across relevant modals
* Automatic diary list refresh when returning to the diary screen
* Application configuration management with a new centralized config system
* 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
- Installation Method 1 (repository compilation)
- Organization of trips by date, location, or theme
- Enhanced photo management features
- Search functionality
- Export features
- Testing implementation
## [0.0.3] - 2025-07-07
### 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
### Changed
* Changed the license in pyproject.toml to BSD
- Changed the license in pyproject.toml to BSD
## [0.0.1] - 2025-07-06
### Added
* Initial alpha release of Pilgrim travel diary application
* Create and edit travel diaries
* Create and edit diary entries
* Photo ingestion system
* Photo addition and reference via sidebar
* Text User Interface (TUI) built with Textual framework
* Pre-compiled binary installation method (Method 2)
* Support for Linux operating systems
* Basic project documentation (README)
- Initial alpha release of Pilgrim travel diary application
- Create and edit travel diaries
- Create and edit diary entries
- Photo ingestion system
- Photo addition and reference via sidebar
- Text User Interface (TUI) built with Textual framework
- Pre-compiled binary installation method (Method 2)
- Support for Linux operating systems
- Basic project documentation (README)
### Known Issues
* Installation Method 1 not yet implemented
* No testing suite implemented yet
* Some features may be unstable in an alpha version
- Installation Method 1 not yet implemented
- No testing suite implemented yet
- 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
+1 -1
View File
@@ -25,7 +25,7 @@ structured and accessible format.
To install the application you must issue the command:
```bash
pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple Pilgrim
pip install pilgrim
```
## Usage
+30 -29
View File
@@ -1,32 +1,33 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "pilgrim"
version = "0.0.4"
authors = [
{ name="Gustavo Henrique Santos Souza de Miranda", email="gustavohssmiranda@gmail.com" }
]
description = "Pilgrim's Travel Log"
readme = "README.md"
requires-python = ">=3.10"
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: BSD License",
"Operating System :: OS Independent",
]
dependencies = [
"sqlalchemy",
"textual",
"tomli",
"tomli_w",
"unidecode"
]
[project.urls]
Homepage = "https://github.com/gmbrax/Pilgrim/"
Issues = "https://github.com/gmbrax/Pilgrim/issues"
[project]
name = "Pilgrim"
version = "0.0.3"
authors = [
{ name="Gustavo Henrique Santos Souza de Miranda", email="gustavohssmiranda@gmail.com" }
]
description = "Pilgrim's Travel Log"
readme = "README.md"
requires-python = ">=3.10"
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: BSD License",
"Operating System :: OS Independent",
]
dependencies = [
"sqlalchemy",
"textual",
[project.scripts]
pilgrim = "pilgrim.command:main"
]
[template.plugins.default]
src-layout = true
[project.urls]
Homepage = "https://github.com/gmbrax/Pilgrim/"
Issues = "https://github.com/gmbrax/Pilgrim/issues"
[tool.hatch.build.targets.wheel]
packages = ["src/pilgrim"]
[project.scripts]
pilgrim = "pilgrim:main"
+1 -1
View File
@@ -3,4 +3,4 @@ SQLAlchemy==2.0.41
typing_extensions==4.14.1
textual~=4.0.0
textual~=3.6.0
+4 -6
View File
@@ -1,21 +1,19 @@
from pilgrim.database import Database
from pilgrim.service.servicemanager import ServiceManager
from pilgrim.ui.ui import UIApp
from pilgrim.utils import ConfigManager
from pilgrim.utils import DirectoryManager
class Application:
def __init__(self):
self.config_manager = ConfigManager()
self.config_manager.read_config() # Chamar antes de criar o Database
self.database = Database(self.config_manager)
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, self.config_manager)
self.ui = UIApp(session_manager)
def run(self):
print(f"URL do banco: {self.config_manager.database_url}")
self.database.create()
self.ui.run()
+28 -14
View File
@@ -1,28 +1,42 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from pathlib import Path
import os
import shutil
from pilgrim.utils import ConfigManager
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, 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)
def __init__(self):
db_path = get_database_path()
self.engine = create_engine(
f"sqlite:///{self.db_path}",
f"sqlite:///{db_path}",
echo=False,
connect_args={"check_same_thread": False},
)
+3 -4
View File
@@ -4,16 +4,15 @@ 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.database import Base
from ..database import Base
class Entry(Base):
__tablename__ = "entries"
id = Column(Integer, primary_key=True)
title = Column(String,nullable=False)
title = Column(String)
text = Column(String)
date = Column(DateTime,nullable=False)
date = Column(DateTime)
photos = relationship(
"Photo",
secondary=photo_entry_association,
+2 -8
View File
@@ -4,11 +4,9 @@ from pathlib import Path
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime
from sqlalchemy.orm import relationship
from sqlalchemy.sql.schema import Index
from pilgrim.models.photo_in_entry import photo_entry_association
from pilgrim.database import Base
from ..database import Base
class Photo(Base):
@@ -25,11 +23,7 @@ class Photo(Base):
back_populates="photos"
)
fk_travel_diary_id = Column(Integer, ForeignKey("travel_diaries.id"), nullable=False)
travel_diary = relationship("TravelDiary", back_populates="photos")
__table_args__ = (
Index('idx_photo_hash_diary', 'hash', 'fk_travel_diary_id'),
)
fk_travel_diary_id = Column(Integer, ForeignKey("travel_diaries.id"),nullable=False)
def __init__(self, filepath, name, photo_hash, addition_date=None, caption=None, entries=None, fk_travel_diary_id=None, **kw: Any):
super().__init__(**kw)
+1 -1
View File
@@ -1,6 +1,6 @@
from sqlalchemy import Table, Column, Integer, ForeignKey
from pilgrim.database import Base
from ..database import Base
photo_entry_association = Table('photo_entry_association', Base.metadata,
Column('id', Integer, primary_key=True, autoincrement=True),
+2 -4
View File
@@ -3,17 +3,15 @@ from typing import Any
from sqlalchemy import Column, Integer, String, UniqueConstraint
from sqlalchemy.orm import relationship
from pilgrim.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, nullable=False)
directory_name = Column(String, nullable=False, unique=True)
entries = relationship("Entry", back_populates="travel_diary", cascade="all, delete-orphan")
photos = relationship("Photo", back_populates="travel_diary", cascade="all, delete-orphan")
__table_args__ = (
UniqueConstraint('directory_name', name='uq_travel_diary_directory_name'),
+3 -3
View File
@@ -1,9 +1,9 @@
from datetime import datetime
from typing import List
from pilgrim.models.entry import Entry
from pilgrim.models.travel_diary import TravelDiary
from pilgrim.models.photo import Photo # ✨ Importe o modelo Photo
from ..models.entry import Entry
from ..models.travel_diary import TravelDiary
from ..models.photo import Photo # ✨ Importe o modelo Photo
class EntryService:
+5 -12
View File
@@ -14,9 +14,8 @@ class PhotoService:
def __init__(self, session):
self.session = session
@staticmethod
def hash_file(filepath: Path) -> str:
"""Calculate the hash of a file using SHA3-384."""
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):
@@ -65,18 +64,10 @@ class PhotoService:
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:
travel_diary = self.session.query(TravelDiary).filter(TravelDiary.id == travel_diary_id).first()
if not travel_diary:
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
copied_path = self._copy_photo_to_diary(filepath, travel_diary)
@@ -88,6 +79,8 @@ class PhotoService:
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
@@ -125,7 +118,7 @@ class PhotoService:
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.photo_hash = self._hash_file(new_path)
original.name = photo_dst.name
original.addition_date = photo_dst.addition_date
+3 -5
View File
@@ -6,8 +6,8 @@ from pathlib import Path
from pilgrim.utils import DirectoryManager
from sqlalchemy.exc import IntegrityError
from pilgrim.models.travel_diary import TravelDiary
from unidecode import unidecode
from ..models.travel_diary import TravelDiary
class TravelDiaryService:
def __init__(self, session):
@@ -20,10 +20,8 @@ class TravelDiaryService:
- Replaces spaces with underscores
- Ensures name is unique by adding a suffix if needed
"""
transliterated_name = unidecode(name)
# Remove special characters and replace spaces
safe_name = re.sub(r'[^\w\s-]', '', transliterated_name)
safe_name = re.sub(r'[^\w\s-]', '', name)
safe_name = safe_name.strip().replace(' ', '_').lower()
# Ensure we have a valid name
+2 -2
View File
@@ -3,7 +3,7 @@ from textual.binding import Binding
from textual.screen import Screen
from textual.widgets import Header, Footer, Button, Label, TextArea
from textual.containers import Container
from importlib.metadata import version
class AboutScreen(Screen[bool]):
"""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.content = Label("A TUI Based Travel Diary Application", id="AboutScreen_AboutContent",
classes="AboutScreen_AboutContent")
self.version = Label(f"Version: {version('Pilgrim')}", id="AboutScreen_AboutVersion",
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",
+21 -10
View File
@@ -206,18 +206,29 @@ class DiaryListScreen(Screen):
"""Action to create new diary"""
self.app.push_screen(NewDiaryModal(),self._on_new_diary_submitted)
def _on_new_diary_submitted(self, result):
"""Callback after diary creation"""
if result: # Se result não é string vazia, o diário foi criado
self.notify(f"Returning to diary list...")
# Atualiza a lista de diários
self.refresh_diaries()
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"Creation canceled...")
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 _on_screen_resume(self) -> None:
super()._on_screen_resume()
self.refresh_diaries()
def action_edit_selected_diary(self):
"""Action to edit selected diary"""
+9 -21
View File
@@ -8,7 +8,6 @@ from textual.widgets import Label, Input, Button
class EditDiaryModal(ModalScreen[tuple[int,str]]):
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
Binding("enter", "edit_diary", "Save",priority=True),
]
def __init__(self, diary_id: int):
@@ -33,28 +32,17 @@ class EditDiaryModal(ModalScreen[tuple[int,str]]):
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "save_diary_button":
self.action_edit_diary()
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 on_key(self, event):
if event.key == "enter":
self.action_edit_diary()
event.prevent_default()
def action_edit_diary(self) -> None:
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()
def on_input_submitted(self, event: Input.Submitted) -> None:
if event.input.id == "edit_diary_name_input":
self.action_edit_diary()
def action_cancel(self) -> None:
self.dismiss(None)
+73 -96
View File
@@ -24,33 +24,26 @@ class EditEntryScreen(Screen):
BINDINGS = [
Binding("ctrl+q", "quit", "Quit"),
Binding("ctrl+s", "save", "Save"),
Binding("shift+f5", "new_entry", "New Entry"),
Binding("f5", "next_entry", "Next Entry"),
Binding("f4", "prev_entry", "Previous Entry"),
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,create_new: bool = True):
def __init__(self, diary_id: int = 1):
super().__init__()
if create_new:
self.current_entry_index = -1
self.is_new_entry = True
self.next_entry_id = None
else:
self.is_new_entry = False
self.current_entry_index = 0
self.next_entry_id = 1
self.new_entry_title = ""
self.new_entry_content = ""
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
@@ -87,7 +80,7 @@ class EditEntryScreen(Screen):
self.text_entry = TextArea(id="text_entry", classes="EditEntryScreen-text-entry")
# 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_info = Static("", classes="EditEntryScreen-sidebar-photo-info")
self.help_text = Static("", classes="EditEntryScreen-sidebar-help")
@@ -296,28 +289,30 @@ class EditEntryScreen(Screen):
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")
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_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
help_text = (
"[b]Sidebar Shortcuts[/b]\n"
"[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"
"\\[\\[photo::hash\\]\\]"
"Use: \\[\\[photo:name:hash\\]\\]\n"
"Or: \\[\\[photo::hash\\]\\]"
)
self.help_text.update(help_text)
except Exception as e:
@@ -427,7 +422,9 @@ class EditEntryScreen(Screen):
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)
@@ -611,8 +608,7 @@ class EditEntryScreen(Screen):
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,
photo_hash=original_photo.photo_hash,
id=original_photo.id
)
result = photo_service.update(original_photo, updated_photo)
@@ -739,15 +735,15 @@ class EditEntryScreen(Screen):
self.notify(f"Selected photo: {selected_photo.name} \\[{photo_hash}\\]")
# Update photo info with details including hash
photo_details = f"Name: {selected_photo.name}\n"
photo_details += f"Hash: {photo_hash}\n"
photo_details += f"Date: {selected_photo.addition_date}\n"
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"Caption: {selected_photo.caption}\n"
else:
photo_details += f"Caption: No Caption\n"
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::{photo_hash}]]"
photo_details += f"\\[\\[photo:{selected_photo.name}:{photo_hash}\\]\\]\n"
photo_details += f"\\[\\[photo::{photo_hash}\\]\\]"
self.photo_info.update(photo_details)
@@ -910,22 +906,11 @@ class EditEntryScreen(Screen):
self.notify("Empty entry cannot be saved")
return
# Passe a lista de fotos para o método de criação
if self.new_entry_title == "":
self.app.push_screen(RenameEntryModal(current_name=""), lambda result: self._handle_save_after_rename(result,content,
photos_to_link))
else:
self.call_later(self._async_create_entry, content, photos_to_link)
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)
def _handle_save_after_rename(self, result: str | None, content: str, photos_to_link: List[Photo]) -> None:
if result is None:
self.notify("Save cancelled")
return
self.new_entry_title = result
self.call_later(self._async_create_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:
@@ -952,7 +937,7 @@ class EditEntryScreen(Screen):
self.is_new_entry = False
self.has_unsaved_changes = False
self._original_content = new_entry.text
self.new_entry_title = ""
self.new_entry_title = "New Entry"
self.next_entry_id = max(entry.id for entry in self.entries) + 1
self._update_entry_display()
@@ -997,60 +982,52 @@ class EditEntryScreen(Screen):
except Exception as e:
self.notify(f"Error updating entry: {str(e)}")
def check_key(self, event):
"""Check for custom key handling before bindings are processed"""
# Sidebar shortcuts
if self.sidebar_focused and self.sidebar_visible:
sidebar_keys = ["i", "n", "d", "e"]
if event.key in sidebar_keys:
if event.key == "i":
self.action_insert_photo()
elif event.key == "n":
self.action_ingest_new_photo()
elif event.key == "d":
self.action_delete_photo()
elif event.key == "e":
self.action_edit_photo()
return True # Indica que o evento foi processado
# Text area shortcuts
elif self.focused is self.text_entry:
if event.key in ["tab", "shift+tab"]:
if event.key == "shift+tab":
self._handle_shift_tab()
elif event.key == "tab":
self.text_entry.insert('\t')
return True # Indica que o evento foi processado
return False # Não foi processado, continuar com bindings
def _handle_shift_tab(self):
"""Handle shift+tab for removing indentation"""
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))
def on_key(self, event):
if self.check_key(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()
return
def on_footer_action(self, event) -> None:
"""Handle clicks on footer actions (Textual 3.x)."""
@@ -15,6 +15,12 @@ class AddPhotoModal(Screen):
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(
@@ -63,16 +69,10 @@ class AddPhotoModal(Screen):
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()
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(
filepath=Path(photo_data["filepath"]),
name=photo_data["name"],
@@ -82,9 +82,13 @@ class AddPhotoModal(Screen):
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: {new_photo.photo_hash[:8]}\nReference: \\[\\[photo:{new_photo.name}:{new_photo.photo_hash[:8]}\\]\\]",
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
@@ -93,7 +97,7 @@ class AddPhotoModal(Screen):
"name": photo_data["name"],
"caption": photo_data["caption"],
"photo_id": new_photo.id,
"hash": new_photo.photo_hash
"hash": photo_hash
}
self.dismiss(self.result)
else:
@@ -3,7 +3,7 @@ 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)"""
@@ -12,11 +12,15 @@ class EditPhotoModal(Screen):
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"),
@@ -41,9 +45,10 @@ class EditPhotoModal(Screen):
id="caption-input",
classes="EditPhotoModal-Input"
),
Static(f"🔗 Photo Hash: {self.photo.photo_hash[:8]}", classes="EditPhotoModal-Hash"),
Static(f"🔗 Photo Hash: {photo_hash}", classes="EditPhotoModal-Hash"),
Static("Reference formats:", classes="EditPhotoModal-Label"),
Static(f"\\[\\[photo::{self.photo.photo_hash[:8]}\\]\\]", classes="EditPhotoModal-Reference"),
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"),
+8 -43
View File
@@ -4,17 +4,13 @@ from textual.containers import Vertical, Horizontal
from textual.screen import ModalScreen
from textual.widgets import Label, Input, Button
from pilgrim.ui.screens.edit_entry_screen import EditEntryScreen
class NewDiaryModal(ModalScreen[str]):
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
Binding("enter", "create_diary", "Create",priority=True),
]
def __init__(self):
super().__init__()
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
def compose(self) -> ComposeResult:
@@ -35,46 +31,15 @@ class NewDiaryModal(ModalScreen[str]):
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handles button clicks."""
if event.button.id == "create_diary_button":
self.action_create_diary()
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()
self.dismiss("")
def action_cancel(self) -> None:
"""Action to cancel the modal."""
self.dismiss("")
def action_create_diary(self) -> None:
diary_name = self.name_input.value.strip()
if diary_name:
self.call_later(self._async_create_diary, diary_name)
else:
self.notify("Diary name cannot be empty.", severity="warning")
self.name_input.focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
if event.input.id == "NewDiaryModal-NameInput":
self.action_create_diary()
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.dismiss(name)
if self.auto_open:
self.app.push_screen(EditEntryScreen(diary_id=created_diary.id))
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)}")
self.dismiss("")
+21 -18
View File
@@ -389,10 +389,22 @@ Screen.-modal {
width: 1fr;
}
.EditEntryScreen-sidebar{
background: $primary-darken-1;
width: 45%;
.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;
@@ -405,39 +417,30 @@ Screen.-modal {
.EditEntryScreen-sidebar-content {
height: 1fr;
layout: vertical;
margin-left: 1;
margin-right: 1;
}
.EditEntryScreen-sidebar-photo-list {
height: 2fr; /* Pega o espaço restante disponível */
min-height: 5; /* Garante altura mínima para não sumir */
height: 1fr;
border: solid $accent;
margin-bottom: 1;
overflow-y: auto; /* Adiciona scroll vertical se necessário */
}
.EditEntryScreen-sidebar-photo-info {
height: 1fr;
max-height: 15; /* Limita altura máxima */
min-height: 13;
padding: 1;
height: auto;
min-height: 3;
border: solid $warning;
padding: 1;
margin-bottom: 1;
background: $surface-darken-1;
overflow-y: auto; /* Adiciona scroll se exceder max-height */
}
.EditEntryScreen-sidebar-help {
height: 1fr;
max-height: 10; /* Altura máxima menor que info */
min-height: 8; /* Altura mínima menor */
height: auto;
min-height: 8;
border: solid $success;
padding: 1;
background: $surface-darken-1;
text-style: italic;
overflow-y: auto; /* Adiciona scroll se exceder max-height */
}
/* Photo Modal Styles */
+1 -3
View File
@@ -9,7 +9,6 @@ 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
from pilgrim.utils import ConfigManager
CSS_FILE_PATH = Path(__file__).parent / "styles" / "pilgrim.css"
@@ -17,10 +16,9 @@ CSS_FILE_PATH = Path(__file__).parent / "styles" / "pilgrim.css"
class UIApp(App):
CSS_PATH = CSS_FILE_PATH
def __init__(self,service_manager: ServiceManager, config_manager: ConfigManager, **kwargs):
def __init__(self,service_manager: ServiceManager, **kwargs):
super().__init__(**kwargs)
self.service_manager = service_manager
self.config_manager = config_manager
def on_mount(self) -> None:
+1 -2
View File
@@ -1,4 +1,3 @@
from .directory_manager import DirectoryManager
from .config_manager import ConfigManager
__all__ = ['DirectoryManager', 'ConfigManager']
__all__ = ['DirectoryManager']
-107
View File
@@ -1,107 +0,0 @@
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,5 +1,4 @@
import os
import shutil
from pathlib import Path
@@ -38,26 +37,3 @@ class DirectoryManager:
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"
@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
-36
View File
@@ -1,36 +0,0 @@
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from pilgrim.database import Base
from pilgrim.models.travel_diary import TravelDiary
# Todos os imports necessários para as fixtures devem estar aqui
# ...
@pytest.fixture(scope="function")
def db_session():
"""Esta fixture agora está disponível para TODOS os testes."""
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):
"""Esta também fica disponível para todos."""
travel_diary = TravelDiary(name="My Travel Diary", directory_name="viagem-teste")
db_session.add(travel_diary)
db_session.commit()
return db_session
@pytest.fixture
def session_with_one_diary(db_session):
diary = TravelDiary(name="Diário de Teste", directory_name="diario_de_teste")
db_session.add(diary)
db_session.commit()
db_session.refresh(diary)
return db_session, diary
-269
View File
@@ -1,269 +0,0 @@
from re import search
import pytest
from datetime import datetime
from unittest.mock import Mock
from sqlalchemy import create_engine
from sqlalchemy.exc import IntegrityError
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
def session_with_an_entry(populated_db_session):
session = populated_db_session
initial_entry = Entry(
title="Título Original",
text="Texto original.",
date=datetime(2025, 1, 1),
travel_diary_id=1
)
session.add(initial_entry)
session.commit()
return session, initial_entry.id
@pytest.fixture
def session_with_multiple_entries(populated_db_session):
"""Fixture que cria um diário e duas entradas para ele."""
session = populated_db_session
entry1 = Entry(title="Entrada 1", text="Texto 1", date=datetime(2025, 1, 1), travel_diary_id=1)
entry2 = Entry(title="Entrada 2", text="Texto 2", date=datetime(2025, 1, 2), travel_diary_id=1)
session.add_all([entry1, entry2])
session.commit()
return 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"
def test_create_entry_fails_with_null_title(populated_db_session):
session = populated_db_session
service = EntryService(session)
diary_id = 1
with pytest.raises(IntegrityError):
service.create(
travel_diary_id=diary_id,
title=None,
text="Um texto qualquer.",
date=datetime.now(),
photos=[]
)
def test_create_entry_fails_with_null_date(populated_db_session):
session = populated_db_session
service = EntryService(session)
diary_id = 1
with pytest.raises(IntegrityError):
service.create(
travel_diary_id=diary_id,
title="Sabado de sol",
text="Um texto qualquer.",
date=None,
photos=[]
)
def test_create_entry_fails_with_null_diary_id(populated_db_session):
session = populated_db_session
service = EntryService(session)
diary_id = 1
result = service.create(
travel_diary_id=None,
title="Sabado de sol",
text="Um texto qualquer.",
date=datetime.now(),
photos=[]
)
assert result is None
def test_ready_by_id_successfully(session_with_an_entry):
session,_ = session_with_an_entry
service = EntryService(session)
search_id = 1
result = service.read_by_id(search_id)
assert result is not None
def test_ready_by_id_fails_when_id_is_invalid(db_session):
session = db_session
service = EntryService(session)
invalid_id = 666
result = service.read_by_id(invalid_id)
assert result is None
def test_read_all_returns_all_entries(session_with_multiple_entries):
session = session_with_multiple_entries
service = EntryService(session)
all_entries = service.read_all()
assert isinstance(all_entries, list)
assert len(all_entries) == 2
assert all_entries[0].title == "Entrada 1"
assert all_entries[1].title == "Entrada 2"
def test_read_all_returns_empty_list_on_empty_db(db_session):
session = db_session
service = EntryService(session)
all_entries = service.read_all()
assert isinstance(all_entries, list)
assert len(all_entries) == 0
def test_update_entry_successfully(session_with_an_entry):
session, entry_id = session_with_an_entry
service = EntryService(session)
entry_src = session.query(Entry).filter_by(id=entry_id).one()
new_date = datetime(2025, 1, 2)
entry_dst = Entry(
title="Título Atualizado",
text="Texto atualizado.",
date=new_date,
travel_diary_id=1, # Mantemos o mesmo travel_diary_id
photos=[]
)
updated_entry = service.update(entry_src, entry_dst)
assert updated_entry is not None
assert updated_entry.id == entry_id
assert updated_entry.title == "Título Atualizado"
assert updated_entry.text == "Texto atualizado."
entry_in_db = session.query(Entry).filter_by(id=entry_id).one()
assert entry_in_db.title == "Título Atualizado"
def test_update_entry_fails_if_entry_does_not_exist(db_session):
service = EntryService(db_session)
non_existent_entry = Entry(
title="dummy",
text="dummy",
date=datetime.now(),
travel_diary_id=1)
non_existent_entry.id = 999
entry_with_new_data = Entry(title="Novo Título", text="Novo Texto", date=datetime.now(), travel_diary_id=1)
result = service.update(non_existent_entry, entry_with_new_data)
assert result is None
def test_update_fails_with_null_title(session_with_an_entry):
session, entry_id = session_with_an_entry
service = EntryService(session)
entry_src = session.query(Entry).filter_by(id=entry_id).one()
entry_dst = Entry(
title=None,
text="Texto atualizado.",
date=datetime.now(),
travel_diary_id=1,
photos=[]
)
with pytest.raises(IntegrityError):
service.update(entry_src, entry_dst)
def test_update_fails_with_null_date(session_with_an_entry):
session, entry_id = session_with_an_entry
service = EntryService(session)
entry_src = session.query(Entry).filter_by(id=entry_id).one()
entry_dst = Entry(
title=entry_src.title,
text="Texto atualizado.",
date=None,
travel_diary_id=1,
photos=[]
)
with pytest.raises(IntegrityError):
service.update(entry_src, entry_dst)
def test_update_fails_with_null_diary_id(session_with_an_entry):
session, entry_id = session_with_an_entry
service = EntryService(session)
entry_src = session.query(Entry).filter_by(id=entry_id).one()
entry_dst = Entry(
title=entry_src.title,
text="Texto atualizado.",
date=datetime.now(),
travel_diary_id=None,
photos=[]
)
with pytest.raises(IntegrityError):
service.update(entry_src, entry_dst)
def test_delete_successfully_removes_entry(session_with_an_entry):
session, entry_id = session_with_an_entry
service = EntryService(session)
entry_to_delete = service.read_by_id(entry_id)
assert entry_to_delete is not None
deleted_entry = service.delete(entry_to_delete)
assert deleted_entry is not None
assert deleted_entry.id == entry_id
entry_in_db = service.read_by_id(entry_id)
assert entry_in_db is None
def test_delete_returns_none_if_entry_does_not_exist(db_session):
service = EntryService(db_session)
non_existent_entry = Entry(
title="dummy",
text="dummy",
date=datetime.now(),
travel_diary_id=1)
non_existent_entry.id = 999
result = service.delete(non_existent_entry)
assert result is None
-143
View File
@@ -1,143 +0,0 @@
from unittest.mock import patch, MagicMock
from pathlib import Path
import pytest
from pilgrim import TravelDiary
from pilgrim.service.travel_diary_service import TravelDiaryService
@patch.object(TravelDiaryService, '_ensure_diary_directory')
@pytest.mark.asyncio # Marca o teste para rodar código assíncrono
async def test_create_diary_successfully(mock_ensure_dir, db_session):
service = TravelDiaryService(db_session)
new_diary = await service.async_create("Viagem para a Serra")
assert new_diary is not None
assert new_diary.id is not None
assert new_diary.name == "Viagem para a Serra"
assert new_diary.directory_name == "viagem_para_a_serra"
@patch.object(TravelDiaryService, '_ensure_diary_directory')
@patch.object(TravelDiaryService, '_sanitize_directory_name', return_value="nome_existente")
@pytest.mark.asyncio
async def test_create_diary_handles_integrity_error(mock_sanitize, mock_ensure_dir, db_session):
existing_diary = TravelDiary(name="Diário Antigo", directory_name="nome_existente")
db_session.add(existing_diary)
db_session.commit()
service = TravelDiaryService(db_session)
with pytest.raises(ValueError, match="Could not create diary"):
await service.async_create("Qualquer Nome Novo")
mock_ensure_dir.assert_not_called()
@patch.object(TravelDiaryService, '_ensure_diary_directory')
def test_read_by_id_successfully(mock_ensure_dir, session_with_one_diary):
session, diary_to_find = session_with_one_diary
service = TravelDiaryService(session)
found_diary = service.read_by_id(diary_to_find.id)
assert found_diary is not None
assert found_diary.id == diary_to_find.id
assert found_diary.name == "Diário de Teste"
mock_ensure_dir.assert_called_once_with(found_diary)
@patch.object(TravelDiaryService, '_ensure_diary_directory')
def test_read_by_id_returns_none_for_invalid_id(mock_ensure_dir, db_session):
service = TravelDiaryService(db_session)
result = service.read_by_id(999)
assert result is None
mock_ensure_dir.assert_not_called()
@patch.object(TravelDiaryService, '_ensure_diary_directory')
def test_read_all_returns_all_diaries(mock_ensure_dir, db_session):
d1 = TravelDiary(name="Diário 1", directory_name="d1")
d2 = TravelDiary(name="Diário 2", directory_name="d2")
db_session.add_all([d1, d2])
db_session.commit()
service = TravelDiaryService(db_session)
diaries = service.read_all()
assert isinstance(diaries, list)
assert len(diaries) == 2
assert mock_ensure_dir.call_count == 2
@patch.object(TravelDiaryService, '_ensure_diary_directory')
def test_read_all_returns_empty_list_for_empty_db(mock_ensure_dir, db_session):
service = TravelDiaryService(db_session)
diaries = service.read_all()
assert isinstance(diaries, list)
assert len(diaries) == 0
mock_ensure_dir.assert_not_called()
@patch.object(TravelDiaryService, '_ensure_diary_directory')
@patch('pathlib.Path.rename')
@patch.object(TravelDiaryService, '_get_diary_directory')
def test_update_diary_successfully(mock_get_dir, mock_path_rename, mock_ensure, session_with_one_diary):
session, diary_to_update = session_with_one_diary
service = TravelDiaryService(session)
old_path = MagicMock(spec=Path) # Um mock que se parece com um objeto Path
old_path.exists.return_value = True # Dizemos que o diretório antigo "existe"
new_path = Path("/fake/path/diario_atualizado")
mock_get_dir.side_effect = [old_path, new_path]
updated_diary = service.update(diary_to_update.id, "Diário Atualizado")
assert updated_diary is not None
assert updated_diary.name == "Diário Atualizado"
assert updated_diary.directory_name == "diario_atualizado"
old_path.rename.assert_called_once_with(new_path)
def test_update_returns_none_for_invalid_id(db_session):
service = TravelDiaryService(db_session)
result = service.update(travel_diary_id=999, name="Nome Novo")
assert result is None
@patch.object(TravelDiaryService, '_cleanup_diary_directory')
def test_delete_diary_successfully(mock_cleanup, session_with_one_diary):
session, diary_to_delete = session_with_one_diary
service = TravelDiaryService(session)
result = service.delete(diary_to_delete)
assert result is not None
assert result.id == diary_to_delete.id
mock_cleanup.assert_called_once_with(diary_to_delete)
diary_in_db = service.read_by_id(diary_to_delete.id)
assert diary_in_db is None
@patch.object(TravelDiaryService, '_cleanup_diary_directory')
def test_delete_returns_none_for_non_existent_diary(mock_cleanup, db_session):
service = TravelDiaryService(db_session)
non_existent_diary = TravelDiary(name="dummy", directory_name="dummy")
non_existent_diary.id = 999
result = service.delete(non_existent_diary)
assert result is None
mock_cleanup.assert_not_called()
@patch.object(TravelDiaryService, '_sanitize_directory_name')
def test_update_raises_value_error_on_name_collision(mock_sanitize, db_session):
d1 = TravelDiary(name="Diário A", directory_name="diario_a")
d2 = TravelDiary(name="Diário B", directory_name="diario_b")
db_session.add_all([d1, d2])
db_session.commit()
db_session.refresh(d1)
mock_sanitize.return_value = "diario_b"
service = TravelDiaryService(db_session)
with pytest.raises(ValueError, match="Could not update diary"):
service.update(d1.id, "Diário B")
def test_sanitize_directory_name_formats_string_correctly(db_session):
service = TravelDiaryService(db_session)
name1 = "Minha Primeira Viagem"
assert service._sanitize_directory_name(name1) == "minha_primeira_viagem"
name2 = "Viagem para o #Rio de Janeiro! @2025"
assert service._sanitize_directory_name(name2) == "viagem_para_o_rio_de_janeiro_2025"
name3 = " Mochilão na Europa "
assert service._sanitize_directory_name(name3) == "mochilao_na_europa"
def test_sanitize_directory_name_handles_uniqueness(db_session):
existing_diary = TravelDiary(name="Viagem para a Praia", directory_name="viagem_para_a_praia")
db_session.add(existing_diary)
db_session.commit()
service = TravelDiaryService(db_session)
new_sanitized_name = service._sanitize_directory_name("Viagem para a Praia")
assert new_sanitized_name == "viagem_para_a_praia_1"
another_diary = TravelDiary(name="Outra Viagem", directory_name="viagem_para_a_praia_1")
db_session.add(another_diary)
db_session.commit()
third_sanitized_name = service._sanitize_directory_name("Viagem para a Praia")
assert third_sanitized_name == "viagem_para_a_praia_2"
-40
View File
@@ -1,40 +0,0 @@
import pytest
from unittest.mock import Mock # A ferramenta para criar nosso "dublê"
from pathlib import Path
from sqlalchemy import inspect, Column, Integer, String
from sqlalchemy.orm import Session
from pilgrim.database import Database,Base
class MockUser(Base):
__tablename__ = 'mock_users'
id = Column(Integer, primary_key=True)
name = Column(String)
@pytest.fixture
def db_instance(tmp_path: Path):
fake_db_path = tmp_path / "test_pilgrim.db"
mock_config_manager = Mock()
mock_config_manager.database_url = str(fake_db_path)
db = Database(mock_config_manager)
return db, fake_db_path
def test_create_database(db_instance):
db, fake_db_path = db_instance
db.create()
assert fake_db_path.exists()
def test_table_creation(db_instance):
db, _ = db_instance
db.create()
inspector = inspect(db.engine)
assert "mock_users" in inspector.get_table_names()
def test_session_returned_corectly(db_instance):
db, _ = db_instance
session = db.session()
assert isinstance(session, Session)
session.close()
-71
View File
@@ -1,71 +0,0 @@
import shutil
from pathlib import Path
from unittest.mock import patch
import pytest
from pilgrim.utils.directory_manager import DirectoryManager
@patch('os.chmod')
@patch('pathlib.Path.home')
def test_get_config_directory_creates_dir_in_fake_home(mock_home, mock_chmod, tmp_path: Path):
mock_home.return_value = tmp_path
expected_config_dir = tmp_path / ".pilgrim"
assert not expected_config_dir.exists()
result_path = DirectoryManager.get_config_directory()
assert result_path == expected_config_dir
assert expected_config_dir.exists()
mock_chmod.assert_called_once_with(expected_config_dir, 0o700)
@patch('shutil.copy2')
@patch('pathlib.Path.home')
def test_get_database_path_no_migration(mock_home, mock_copy, tmp_path: Path):
mock_home.return_value = tmp_path
expected_db_path = tmp_path / ".pilgrim" / "database.db"
result_path = DirectoryManager.get_database_path()
assert result_path == expected_db_path
mock_copy.assert_not_called()
@patch('shutil.copy2')
@patch('pathlib.Path.home')
def test_get_database_path_with_migration(mock_home, mock_copy, tmp_path: Path, monkeypatch):
fake_home_dir = tmp_path / "home"
fake_project_dir = tmp_path / "project"
fake_home_dir.mkdir()
fake_project_dir.mkdir()
(fake_project_dir / "database.db").touch()
mock_home.return_value = fake_home_dir
monkeypatch.chdir(fake_project_dir)
result_path = DirectoryManager.get_database_path()
expected_db_path = fake_home_dir / ".pilgrim" / "database.db"
assert result_path == expected_db_path
mock_copy.assert_called_once_with(
Path("database.db"),
expected_db_path
)
@patch('os.chmod')
@patch('pathlib.Path.home')
def test_diary_path_methods_construct_correctly(mock_home, mock_chmod, tmp_path: Path):
mock_home.return_value = tmp_path
images_path = DirectoryManager.get_diary_images_directory("minha-viagem")
expected_path = tmp_path / ".pilgrim" / "diaries" / "minha-viagem" / "data" / "images"
assert images_path == expected_path
assert (tmp_path / ".pilgrim" / "diaries").exists()
@patch('shutil.copy2')
@patch('pathlib.Path.home')
def test_get_database_path_handles_migration_error(mock_home, mock_copy, tmp_path: Path, monkeypatch):
fake_home_dir = tmp_path / "home"
fake_project_dir = tmp_path / "project"
fake_home_dir.mkdir()
fake_project_dir.mkdir()
(fake_project_dir / "database.db").touch()
mock_home.return_value = fake_home_dir
mock_copy.side_effect = shutil.Error("O disco está cheio!")
monkeypatch.chdir(fake_project_dir)
with pytest.raises(RuntimeError, match="Failed to migrate database"):
DirectoryManager.get_database_path()
mock_copy.assert_called_once()