Compare commits

..
Author SHA1 Message Date
Gustavo Henrique Miranda cad6a93dd4 Merge pull request #2 from gmbrax/refactor/refactoring_basics
Refactor/refactoring basics
2025-06-16 14:04:37 -03:00
39 changed files with 113 additions and 2766 deletions
-2
View File
@@ -1,8 +1,6 @@
# Database files # Database files
database.db database.db
.build-vend/
dist_nuitka/
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
+1 -3
View File
@@ -18,9 +18,7 @@ classifiers = [
"Operating System :: OS Independent", "Operating System :: OS Independent",
] ]
dependencies = [ dependencies = [
"sqlalchemy", "sqlalchemy"
"textual",
"textual-dev"
] ]
[template.plugins.default] [template.plugins.default]
src-layout = true src-layout = true
-3
View File
@@ -1,6 +1,3 @@
greenlet==3.2.3 greenlet==3.2.3
SQLAlchemy==2.0.41 SQLAlchemy==2.0.41
typing_extensions==4.14.0 typing_extensions==4.14.0
textual~=3.3.0
@@ -1,19 +1,13 @@
from pilgrim.database import Database from src.database import Database
from pilgrim.service.servicemanager import ServiceManager from src.service.servicemanager import ServiceManager
from pilgrim.ui.ui import UIApp
class Application: class Application:
def __init__(self): def __init__(self):
self.database = Database() self.database = Database()
session = self.database.session()
session_manager = ServiceManager()
session_manager.set_session(session)
self.ui = UIApp(session_manager)
def run(self): def run(self):
self.database.create() self.database.create()
self.ui.run()
def get_service_manager(self): def get_service_manager(self):
session = self.database.session() session = self.database.session()
+6
View File
@@ -0,0 +1,6 @@
from src.application import Application
def main():
app = Application()
app.run()
+2 -6
View File
@@ -4,7 +4,6 @@ from sqlalchemy.orm import sessionmaker
Base = declarative_base() Base = declarative_base()
class Database: class Database:
def __init__(self): def __init__(self):
self.engine = create_engine( self.engine = create_engine(
@@ -12,13 +11,10 @@ class Database:
echo=False, echo=False,
connect_args={"check_same_thread": False}, connect_args={"check_same_thread": False},
) )
self._session_maker = sessionmaker(bind=self.engine, autoflush=False, autocommit=False) self.session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
def create(self): def create(self):
Base.metadata.create_all(self.engine) Base.metadata.create_all(self.engine)
def session(self):
return self._session_maker()
def get_db(self): def get_db(self):
return self._session_maker() return self.session()
@@ -1,10 +1,10 @@
from typing import Any from typing import Any
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from pilgrim.models.photo_in_entry import photo_entry_association from src.models.photo_in_entry import photo_entry_association
from ..database import Base from src.database import Base
class Entry(Base): class Entry(Base):
@@ -12,7 +12,7 @@ class Entry(Base):
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
title = Column(String) title = Column(String)
text = Column(String) text = Column(String)
date = Column(DateTime) date = Column(String)
photos = relationship( photos = relationship(
"Photo", "Photo",
secondary=photo_entry_association, secondary=photo_entry_association,
+31
View File
@@ -0,0 +1,31 @@
from typing import Any
from sqlalchemy.orm import relationship
from sqlalchemy import Column, Integer, String, ForeignKey
from src.models.photo_in_entry import photo_entry_association
from src.database import Base
class Photo(Base):
__tablename__ = "photos"
id = Column(Integer, primary_key=True)
filepath = Column(String)
name = Column(String)
addition_date = Column(String)
caption = Column(String)
entries = relationship(
"Entry",
secondary=photo_entry_association,
back_populates="photos"
)
fk_travel_diary_id = Column(Integer, ForeignKey("travel_diaries.id"),nullable=False)
def __init__(self, filepath, name, addition_date=None, caption=None, entries=None, **kw: Any):
super().__init__(**kw)
self.filepath = filepath
self.name = name
self.addition_date = addition_date
self.caption = caption
self.entries = entries
@@ -1,6 +1,6 @@
from sqlalchemy import Table, Column, Integer, ForeignKey from sqlalchemy import Table, Column, Integer, ForeignKey
from ..database import Base from src.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),
@@ -1,8 +1,9 @@
from typing import Any from typing import Any
from src.database import Base
from sqlalchemy import Column, String, Integer from sqlalchemy import Column, String, Integer
from ..database import Base
class TravelDiary(Base): class TravelDiary(Base):
__tablename__ = "travel_diaries" __tablename__ = "travel_diaries"
-8
View File
@@ -1,8 +0,0 @@
from pilgrim.application import Application
from pilgrim.command import main
from pilgrim.database import Database, Base
from pilgrim.models.travel_diary import TravelDiary
from pilgrim.models.entry import Entry
from pilgrim.models.photo import Photo
__all__ = ["Application", "Database", "TravelDiary", "Entry", "Photo", "main", "Base"]
-9
View File
@@ -1,9 +0,0 @@
from pilgrim.application import Application
def main():
app = Application()
app.run()
if __name__ == "__main__":
main()
-39
View File
@@ -1,39 +0,0 @@
from typing import Any
from datetime import datetime
from pathlib import Path
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime
from sqlalchemy.orm import relationship
from pilgrim.models.photo_in_entry import photo_entry_association
from ..database import Base
class Photo(Base):
__tablename__ = "photos"
id = Column(Integer, primary_key=True)
filepath = Column(String)
name = Column(String)
addition_date = Column(DateTime, default=datetime.now)
caption = Column(String)
entries = relationship(
"Entry",
secondary=photo_entry_association,
back_populates="photos"
)
fk_travel_diary_id = Column(Integer, ForeignKey("travel_diaries.id"),nullable=False)
def __init__(self, filepath, name, addition_date=None, caption=None, entries=None, fk_travel_diary_id=None, **kw: Any):
super().__init__(**kw)
# Convert Path to string if needed
if isinstance(filepath, Path):
self.filepath = str(filepath)
else:
self.filepath = filepath
self.name = name
self.addition_date = addition_date if addition_date is not None else datetime.now()
self.caption = caption
self.entries = entries if entries is not None else []
if fk_travel_diary_id is not None:
self.fk_travel_diary_id = fk_travel_diary_id
@@ -1,123 +0,0 @@
from typing import List, Tuple
import asyncio
from pilgrim.service.entry_service import EntryService
from pilgrim.models.entry import Entry
class EntryServiceMock(EntryService):
def __init__(self):
super().__init__(None)
self.mock_data = {
1: Entry(title="The Adventure Begins", text="I'm hopping in the Plane to finally visit canadian lands",
date="26/07/2025", travel_diary_id=1, id=1,
photos=[]),
2: Entry(title="The Landing", text="Finally on Canadian Soil", date="27/07/2025",
travel_diary_id=1, id=2,photos=[]),
3: Entry(title="The Mount Royal", text="The Mount Royal is fucking awesome", date="28/07/2025",
travel_diary_id=1, id=3, photos=[]),
4: Entry(title="Old Montreal", text="Exploring the historic district", date="29/07/2025",
travel_diary_id=1, id=4, photos=[]),
5: Entry(title="Notre-Dame Basilica", text="Beautiful architecture", date="30/07/2025",
travel_diary_id=1, id=5, photos=[]),
6: Entry(title="Parc Jean-Drapeau", text="Great views of the city", date="31/07/2025",
travel_diary_id=1, id=6, photos=[]),
7: Entry(title="La Ronde", text="Amusement park fun", date="01/08/2025",
travel_diary_id=1, id=7, photos=[]),
8: Entry(title="Biodome", text="Nature and science", date="02/08/2025",
travel_diary_id=1, id=8, photos=[]),
9: Entry(title="Botanical Gardens", text="Peaceful walk", date="03/08/2025",
travel_diary_id=1, id=9, photos=[]),
10: Entry(title="Olympic Stadium", text="Historic venue", date="04/08/2025",
travel_diary_id=1, id=10, photos=[]),
}
self._next_id = 11
# Synchronous methods (kept for compatibility)
def create(self, travel_diary_id: int, title: str, text: str, date: str) -> Entry:
"""Synchronous version"""
new_entry = Entry(title, text, date, travel_diary_id, id=self._next_id)
self.mock_data[self._next_id] = new_entry
self._next_id += 1
return new_entry
def read_by_id(self, entry_id: int) -> Entry | None:
"""Synchronous version"""
return self.mock_data.get(entry_id)
def read_all(self) -> List[Entry]:
"""Synchronous version"""
return list(self.mock_data.values())
def read_by_travel_diary_id(self, travel_diary_id: int) -> List[Entry]:
"""Synchronous version - reads entries by diary"""
return [entry for entry in self.mock_data.values() if entry.fk_travel_diary_id == travel_diary_id]
def read_paginated(self, travel_diary_id: int, page: int = 1, page_size: int = 5) -> Tuple[List[Entry], int, int]:
"""Synchronous version - reads paginated entries by diary"""
entries = self.read_by_travel_diary_id(travel_diary_id)
entries.sort(key=lambda x: x.id, reverse=True) # Most recent first
total_entries = len(entries)
total_pages = (total_entries + page_size - 1) // page_size
start_index = (page - 1) * page_size
end_index = start_index + page_size
page_entries = entries[start_index:end_index]
return page_entries, total_pages, total_entries
def update(self, entry_src: Entry, entry_dst: Entry) -> Entry | None:
"""Synchronous version"""
item_to_update = self.mock_data.get(entry_src.id)
if item_to_update:
item_to_update.title = entry_dst.title if entry_dst.title is not None else item_to_update.title
item_to_update.text = entry_dst.text if entry_dst.text is not None else item_to_update.text
item_to_update.date = entry_dst.date if entry_dst.date is not None else item_to_update.date
item_to_update.fk_travel_diary_id = entry_dst.fk_travel_diary_id if (entry_dst.fk_travel_diary_id
is not None) else entry_dst.id
item_to_update.photos.extend(entry_dst.photos)
return item_to_update
return None
def delete(self, entry_src: Entry) -> Entry | None:
"""Synchronous version"""
return self.mock_data.pop(entry_src.id, None)
# Async methods (main)
async def async_create(self, travel_diary_id: int, title: str, text: str, date: str) -> Entry:
"""Async version"""
await asyncio.sleep(0.01) # Simulates I/O
return self.create(travel_diary_id, title, text, date)
async def async_read_by_id(self, entry_id: int) -> Entry | None:
"""Async version"""
await asyncio.sleep(0.01) # Simulates I/O
return self.read_by_id(entry_id)
async def async_read_all(self) -> List[Entry]:
"""Async version"""
await asyncio.sleep(0.01) # Simulates I/O
return self.read_all()
async def async_read_by_travel_diary_id(self, travel_diary_id: int) -> List[Entry]:
"""Async version - reads entries by diary"""
await asyncio.sleep(0.01) # Simulates I/O
return self.read_by_travel_diary_id(travel_diary_id)
async def async_read_paginated(self, travel_diary_id: int, page: int = 1, page_size: int = 5) -> Tuple[List[Entry], int, int]:
"""Async version - reads paginated entries by diary"""
await asyncio.sleep(0.01) # Simulates I/O
return self.read_paginated(travel_diary_id, page, page_size)
async def async_update(self, entry_src: Entry, entry_dst: Entry) -> Entry | None:
"""Async version"""
await asyncio.sleep(0.01) # Simulates I/O
return self.update(entry_src, entry_dst)
async def async_delete(self, entry_src: Entry) -> Entry | None:
"""Async version"""
await asyncio.sleep(0.01) # Simulates I/O
return self.delete(entry_src)
@@ -1,48 +0,0 @@
from pathlib import Path
from typing import List
from pilgrim.models.photo import Photo
from pilgrim.service.photo_service import PhotoService
class PhotoServiceMock(PhotoService):
def __init__(self):
super().__init__(None)
self.mock_data = {}
self._next_id = 1
def create(self, filepath: Path, name: str, travel_diary_id, addition_date=None, caption=None) -> Photo | None:
new_photo = Photo(
filepath=filepath,
name=name,
addition_date=addition_date,
caption=caption,
fk_travel_diary_id=travel_diary_id
)
new_photo.id = self._next_id
self.mock_data[self._next_id] = new_photo
self._next_id += 1
return new_photo
def read_by_id(self, photo_id: int) -> Photo:
return self.mock_data.get(photo_id)
def read_all(self) -> List[Photo]:
return list(self.mock_data.values())
def update(self, photo_src: Photo, photo_dst: Photo) -> Photo | None:
item_to_update: Photo = self.mock_data.get(photo_src.id)
if item_to_update:
item_to_update.filepath = photo_dst.filepath if photo_dst.filepath else item_to_update.filepath
item_to_update.name = photo_dst.name if photo_dst.name else item_to_update.name
item_to_update.caption = photo_dst.caption if photo_dst.caption else item_to_update.caption
item_to_update.addition_date = photo_dst.addition_date if photo_dst.addition_date else item_to_update.addition_date
item_to_update.fk_travel_diary_id = photo_dst.fk_travel_diary_id if photo_dst.fk_travel_diary_id else item_to_update.fk_travel_diary_id
if photo_dst.entries:
item_to_update.entries = photo_dst.entries
return item_to_update
return None
def delete(self, photo_src: Photo) -> Photo | None:
return self.mock_data.pop(photo_src.id, None)
@@ -1,23 +0,0 @@
from pilgrim.service.mocks.entry_service_mock import EntryServiceMock
from pilgrim.service.mocks.photo_service_mock import PhotoServiceMock
from pilgrim.service.mocks.travel_diary_service_mock import TravelDiaryServiceMock
from pilgrim.service.photo_service import PhotoService
from pilgrim.service.servicemanager import ServiceManager
class ServiceManagerMock(ServiceManager):
def __init__(self):
super().__init__()
# Cria instâncias únicas para manter estado consistente
self._travel_diary_service = TravelDiaryServiceMock()
self._entry_service = EntryServiceMock()
self._photo_service = PhotoServiceMock()
def get_entry_service(self):
return self._entry_service
def get_travel_diary_service(self):
return self._travel_diary_service
def get_photo_service(self):
return self._photo_service
@@ -1,67 +0,0 @@
from pilgrim.service.travel_diary_service import TravelDiaryService
from pilgrim.models.travel_diary import TravelDiary
import asyncio
class TravelDiaryServiceMock(TravelDiaryService):
def __init__(self):
super().__init__(None)
self.mock_data = {
1: TravelDiary(id=1, name="Montreal"),
2: TravelDiary(id=2, name="Rio de Janeiro"),
}
self._next_id = 3
# Synchronous methods (original)
def create(self, name: str):
"""Synchronous version"""
new_travel_diary = TravelDiary(id=self._next_id, name=name)
self.mock_data[self._next_id] = new_travel_diary
self._next_id += 1
return new_travel_diary
def read_by_id(self, travel_id: int):
"""Synchronous version"""
return self.mock_data.get(travel_id)
def read_all(self):
"""Synchronous version"""
return list(self.mock_data.values())
def update(self, travel_diary_id: int, name: str):
"""Synchronous version"""
item_to_update = self.mock_data.get(travel_diary_id)
if item_to_update:
item_to_update.name = name
return item_to_update
return None
def delete(self, travel_diary_id: int):
"""Synchronous version"""
return self.mock_data.pop(travel_diary_id, None)
# Async methods (new)
async def async_create(self, name: str):
"""Async version"""
await asyncio.sleep(0.01) # Simulates I/O
return self.create(name)
async def async_read_by_id(self, travel_id: int):
"""Async version"""
await asyncio.sleep(0.01) # Simulates I/O
return self.read_by_id(travel_id)
async def async_read_all(self):
"""Async version"""
await asyncio.sleep(0.01) # Simulates I/O
return self.read_all()
async def async_update(self, travel_diary_id: int, name: str):
"""Async version"""
await asyncio.sleep(0.01) # Simulates I/O
return self.update(travel_diary_id, name)
async def async_delete(self, travel_diary_id: int):
"""Async version"""
await asyncio.sleep(0.01) # Simulates I/O
return self.delete(travel_diary_id)
-76
View File
@@ -1,76 +0,0 @@
from pathlib import Path
from typing import List
from datetime import datetime
from pilgrim.models.photo import Photo
from pilgrim.models.travel_diary import TravelDiary
class PhotoService:
def __init__(self, session):
self.session = session
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
# Convert addition_date string to datetime if needed
if isinstance(addition_date, str):
try:
addition_date = datetime.strptime(addition_date, "%Y-%m-%d %H:%M:%S")
except ValueError:
addition_date = None
new_photo = Photo(
filepath=filepath,
name=name,
caption=caption,
fk_travel_diary_id=travel_diary_id,
addition_date=addition_date
)
self.session.add(new_photo)
self.session.commit()
self.session.refresh(new_photo)
return new_photo
def read_by_id(self, photo_id:int) -> Photo:
return self.session.query(Photo).get(photo_id)
def read_all(self) -> List[Photo]:
return self.session.query(Photo).all()
def update(self, photo_src: Photo, photo_dst: Photo) -> Photo | None:
original: Photo = self.read_by_id(photo_src.id)
if original:
original.filepath = photo_dst.filepath
original.name = photo_dst.name
original.addition_date = photo_dst.addition_date
original.caption = photo_dst.caption
if photo_dst.entries and len(photo_dst.entries) > 0:
if original.entries is None:
original.entries = []
original.entries = photo_dst.entries # Replace instead of extend
self.session.commit()
self.session.refresh(original)
return original
return None
def delete(self, photo_src: Photo) -> Photo | None:
excluded = self.read_by_id(photo_src.id)
if excluded:
# Store photo data before deletion
deleted_photo = Photo(
filepath=excluded.filepath,
name=excluded.name,
addition_date=excluded.addition_date,
caption=excluded.caption,
fk_travel_diary_id=excluded.fk_travel_diary_id,
id=excluded.id
)
self.session.delete(excluded)
self.session.commit()
return deleted_photo
return None
View File
View File
-73
View File
@@ -1,73 +0,0 @@
from textual.app import ComposeResult
from textual.binding import Binding
from textual.screen import Screen
from textual.widgets import Header, Footer, Button, Label, TextArea
from textual.containers import Container
class AboutScreen(Screen[bool]):
"""Screen to display application information."""
TITLE = "Pilgrim - About"
BINDINGS = [
Binding("escape", "dismiss", "Close"),
]
def __init__(self):
super().__init__()
self.header = Header()
self.footer = Footer()
self.app_title = Label("Pilgrim", id="AboutScreen_AboutTitle",classes="AboutScreen_AboutTitle")
self.content = Label("A TUI Based Travel Diary Application", id="AboutScreen_AboutContent",
classes="AboutScreen_AboutContent")
self.version = Label("Version: 0.0.1", id="AboutScreen_AboutVersion",
classes="AboutScreen_AboutVersion")
self.developer = Label("Developed By: Gustavo Henrique Miranda ", id="AboutScreen_AboutAuthor")
self.contact = Label("git.gustavomiranda.xyz", id="AboutScreen_AboutContact",
classes="AboutScreen_AboutContact")
self.license = TextArea(id="AboutScreen_AboutLicense",
classes="AboutScreen_AboutLicense")
self.license.text = """Copyright (c) 2025 GHMiranda.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE."""
self.license.read_only = True
self.about_container = Container(self.app_title, self.content, self.version, self.developer,
self.contact, id="AboutScreen_SubContainer",
classes="AboutScreen_SubContainer")
self.container = Container(self.about_container, self.license, id="AboutScreen_AboutContainer",
classes="AboutScreen_AboutContainer")
def compose(self) -> ComposeResult:
yield self.header
yield self.container
yield self.footer
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handles button clicks."""
if "about-close-button" in event.button.classes:
self.dismiss(False)
elif "about-info-button" in event.button.classes:
self.notify("More information would be displayed here!", title="Info")
def action_dismiss(self, **kwargs) -> None:
"""Closes the about box using dismiss.
:param **kwargs:
"""
self.dismiss(False)
def on_key(self, event) -> None:
"""Intercepts specific keys."""
if event.key == "escape":
self.dismiss(False)
event.prevent_default()
elif event.key == "enter":
self.dismiss(False)
event.prevent_default()
-299
View File
@@ -1,299 +0,0 @@
from typing import Optional, Tuple
import asyncio
from textual.app import ComposeResult
from textual.screen import Screen
from textual.widgets import Header, Footer, Label, Static, OptionList, Button
from textual.binding import Binding
from textual.containers import Vertical, Container, Horizontal
from pilgrim.models.travel_diary import TravelDiary
from pilgrim.ui.screens.about_screen import AboutScreen
from pilgrim.ui.screens.edit_diary_modal import EditDiaryModal
from pilgrim.ui.screens.new_diary_modal import NewDiaryModal
from pilgrim.ui.screens.edit_entry_screen import EditEntryScreen
class DiaryListScreen(Screen):
TITLE = "Pilgrim - Main"
BINDINGS = [
Binding("n", "new_diary", "New diary"),
Binding("^q", "quit", "Quit Pilgrim"),
Binding("enter", "open_selected_diary", "Open diary"),
Binding("e", "edit_selected_diary", "Edit diary"),
Binding("r", "force_refresh", "Force refresh"),
]
def __init__(self):
super().__init__()
self.selected_diary_index = None
self.diary_id_map = {}
self.is_refreshing = False
self.header = Header()
self.footer = Footer()
self.diary_list = OptionList(classes="DiaryListScreen-DiaryListOptions")
self.new_diary_button = Button("New diary", id="new_diary", classes="DiaryListScreen-NewDiaryButton")
self.edit_diary_button = Button("Edit diary", id="edit_diary", classes="DiaryListScreen-EditDiaryButton")
self.open_diary = Button("Open diary", id="open_diary", classes="DiaryListScreen-OpenDiaryButton")
self.buttons_grid = Horizontal(
self.new_diary_button, self.edit_diary_button, self.open_diary,
classes="DiaryListScreen-ButtonsGrid"
)
self.tips = Static(
"Tip: use ↑↓ to navigate • ENTER to Select • "
"TAB to alternate the fields • SHIFT + TAB to alternate back • "
"Ctrl+P for command palette • R to force refresh",
classes="DiaryListScreen-DiaryListTips"
)
self.container = Container(
self.diary_list, self.buttons_grid, self.tips,
classes="DiaryListScreen-DiaryListContainer"
)
def compose(self) -> ComposeResult:
yield self.header
yield self.container
yield self.footer
def on_mount(self) -> None:
# Uses synchronous version for initial mount
self.refresh_diaries()
self.update_buttons_state()
def refresh_diaries(self):
"""Synchronous version of refresh"""
try:
service_manager = self.app.service_manager
travel_diary_service = service_manager.get_travel_diary_service()
# Uses synchronous method
diaries = travel_diary_service.read_all()
# Saves current state
current_diary_id = None
if (self.selected_diary_index is not None and
self.selected_diary_index in self.diary_id_map):
current_diary_id = self.diary_id_map[self.selected_diary_index]
# Clears and rebuilds
self.diary_list.clear_options()
self.diary_id_map = {}
if not diaries:
self.diary_list.add_option("[dim]No diaries found. Press 'N' to create a new one![/dim]")
self.selected_diary_index = None
else:
new_selected_index = 0
for index, diary in enumerate(diaries):
self.diary_id_map[index] = diary.id
self.diary_list.add_option(f"[b]{diary.name}[/b]\n[dim]ID: {diary.id}[/dim]")
# Maintains selection if possible
if current_diary_id and diary.id == current_diary_id:
new_selected_index = index
self.selected_diary_index = new_selected_index
# Updates highlight
self.set_timer(0.05, lambda: self._update_highlight(new_selected_index))
# Forces visual refresh
self.diary_list.refresh()
self.update_buttons_state()
except Exception as e:
self.notify(f"Error loading diaries: {str(e)}")
def _update_highlight(self, index: int):
"""Updates the OptionList highlight"""
try:
if index < len(self.diary_list.options):
self.diary_list.highlighted = index
self.diary_list.refresh()
except Exception as e:
self.notify(f"Error updating highlight: {str(e)}")
async def async_refresh_diaries(self):
"""Async version of refresh"""
if self.is_refreshing:
return
self.is_refreshing = True
try:
service_manager = self.app.service_manager
travel_diary_service = service_manager.get_travel_diary_service()
# Usa método síncrono agora
diaries = travel_diary_service.read_all()
# Saves current state
current_diary_id = None
if (self.selected_diary_index is not None and
self.selected_diary_index in self.diary_id_map):
current_diary_id = self.diary_id_map[self.selected_diary_index]
# Clears and rebuilds
self.diary_list.clear_options()
self.diary_id_map = {}
if not diaries:
self.diary_list.add_option("[dim]No diaries found. Press 'N' to create a new one![/dim]")
self.selected_diary_index = None
else:
new_selected_index = 0
for index, diary in enumerate(diaries):
self.diary_id_map[index] = diary.id
self.diary_list.add_option(f"[b]{diary.name}[/b]\n[dim]ID: {diary.id}[/dim]")
if current_diary_id and diary.id == current_diary_id:
new_selected_index = index
self.selected_diary_index = new_selected_index
self.set_timer(0.05, lambda: self._update_highlight(new_selected_index))
self.diary_list.refresh()
self.update_buttons_state()
except Exception as e:
self.notify(f"Error loading diaries: {str(e)}")
finally:
self.is_refreshing = False
def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None:
"""Handle when an option is highlighted"""
if self.diary_id_map and event.option_index in self.diary_id_map:
self.selected_diary_index = event.option_index
else:
self.selected_diary_index = None
self.update_buttons_state()
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
"""Handle when an option is selected"""
if self.diary_id_map and event.option_index in self.diary_id_map:
self.selected_diary_index = event.option_index
self.action_open_diary()
else:
self.selected_diary_index = None
self.update_buttons_state()
def update_buttons_state(self):
"""Updates button states"""
has_selection = (self.selected_diary_index is not None and
self.selected_diary_index in self.diary_id_map)
self.edit_diary_button.disabled = not has_selection
self.open_diary.disabled = not has_selection
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button clicks"""
button_id = event.button.id
if button_id == "new_diary":
self.action_new_diary()
elif button_id == "edit_diary":
self.action_edit_selected_diary()
elif button_id == "open_diary":
self.action_open_diary()
def action_new_diary(self):
"""Action to create new diary"""
self.app.push_screen(NewDiaryModal(),self._on_new_diary_submitted)
def _on_new_diary_submitted(self,result):
self.notify(str(result))
if result:
self.notify(f"Creating Diary:{result}'...")
self.call_later(self._async_create_diary,result)
else:
self.notify(f"Canceled...")
async def _async_create_diary(self,name: str):
try:
service = self.app.service_manager.get_travel_diary_service()
created_diary = await service.async_create(name)
if created_diary:
self.diary_id_map[created_diary.id] = created_diary.id
await self.async_refresh_diaries()
self.notify(f"Diary: '{name}' created!")
else:
self.notify("Error Creating the diary")
except Exception as e:
self.notify(f"Exception on creating the diary: {str(e)}")
def action_edit_selected_diary(self):
"""Action to edit selected diary"""
if self.selected_diary_index is not None:
diary_id = self.diary_id_map.get(self.selected_diary_index)
if diary_id:
self.app.push_screen(
EditDiaryModal(diary_id=diary_id),
self._on_edited_diary_name_submitted
)
else:
self.notify("Select a diary to edit")
def action_open_diary(self):
"""Action to open selected diary"""
if self.selected_diary_index is not None:
diary_id = self.diary_id_map.get(self.selected_diary_index)
if diary_id:
self.app.push_screen(EditEntryScreen(diary_id=diary_id))
self.notify(f"Opening diary ID: {diary_id}")
else:
self.notify("Invalid diary ID")
else:
self.notify("Select a diary to open")
def _on_edited_diary_name_submitted(self, result: Optional[Tuple[int, str]]) -> None:
"""Callback after diary editing"""
if result:
diary_id, name = result
self.notify(f"Updating diary ID {diary_id} to '{name}'...")
# Schedules async update
self.call_later(self._async_update_diary, diary_id, name)
else:
self.notify("Edit canceled")
async def _async_update_diary(self, diary_id: int, name: str):
"""Updates the diary asynchronously"""
try:
service = self.app.service_manager.get_travel_diary_service()
updated_diary = await service.async_update(diary_id, name)
if updated_diary:
self.notify(f"Diary '{name}' updated!")
# Forces refresh after update
await self.async_refresh_diaries()
else:
self.notify("Error: Diary not found")
except Exception as e:
self.notify(f"Error updating: {str(e)}")
def action_force_refresh(self):
"""Forces manual refresh"""
self.notify("Forcing refresh...")
# Tries both versions
self.refresh_diaries() # Synchronous
self.call_later(self.async_refresh_diaries) # Asynchronous
def action_open_selected_diary(self):
"""Action for ENTER binding"""
self.action_open_diary()
def action_about_cmd(self):
self.app.push_screen(AboutScreen())
def action_quit(self):
"""Action to quit the application"""
self.app.exit()
@@ -1,47 +0,0 @@
from textual.app import ComposeResult
from textual.containers import Vertical, Horizontal
from textual.screen import ModalScreen
from textual.widgets import Label, Input, Button
class EditDiaryModal(ModalScreen[tuple[int,str]]):
BINDINGS = [
("escape", "cancel", "Cancel"),
]
def __init__(self, diary_id: int):
super().__init__()
self.diary_id = diary_id
self.current_diary_name = self.app.service_manager.get_travel_diary_service().read_by_id(self.diary_id).name
self.name_input = Input(value=self.current_diary_name, id="edit_diary_name_input", classes="EditDiaryModal-NameInput")
def compose(self) -> ComposeResult:
with Vertical(id="edit_diary_dialog", classes="EditDiaryModal-Dialog"):
yield Label("Edit Diary", classes="EditDiaryModal-Title")
yield Label("New Diary Name:")
yield self.name_input
with Horizontal(classes="EditDiaryModal-ButtonsContainer"):
yield Button("Save", variant="primary", id="save_diary_button", classes="EditDiaryModal-SaveButton")
yield Button("Cancel", variant="default", id="cancel_button", classes="EditDiaryModal-CancelButton")
def on_mount(self) -> None:
"""Focuses on the input field and moves cursor to the end of text."""
self.name_input.focus()
self.name_input.cursor_position = len(self.name_input.value)
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "save_diary_button":
new_diary_name = self.name_input.value.strip()
if new_diary_name and new_diary_name != self.current_diary_name:
self.dismiss((self.diary_id, new_diary_name))
elif new_diary_name == self.current_diary_name:
self.notify("No changes made.", severity="warning")
self.dismiss(None)
else:
self.notify("Diary name cannot be empty.", severity="warning")
self.name_input.focus()
elif event.button.id == "cancel_button":
self.dismiss(None)
def action_cancel(self) -> None:
self.dismiss(None)
-842
View File
@@ -1,842 +0,0 @@
from typing import Optional, List
import asyncio
from datetime import datetime
from pathlib import Path
from textual.app import ComposeResult
from textual.screen import Screen
from textual.widgets import Header, Footer, Static, TextArea, OptionList, Input, Button
from textual.binding import Binding
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
from pilgrim.models.entry import Entry
from pilgrim.models.travel_diary import TravelDiary
from pilgrim.models.photo import Photo
from pilgrim.ui.screens.modals.add_photo_modal import AddPhotoModal
from pilgrim.ui.screens.modals.edit_photo_modal import EditPhotoModal
from pilgrim.ui.screens.modals.confirm_delete_modal import ConfirmDeleteModal
from pilgrim.ui.screens.modals.file_picker_modal import FilePickerModal
from pilgrim.ui.screens.rename_entry_modal import RenameEntryModal
class EditEntryScreen(Screen):
TITLE = "Pilgrim - Edit"
BINDINGS = [
Binding("ctrl+s", "save", "Save"),
Binding("ctrl+n", "next_entry", "Next/New Entry"),
Binding("ctrl+b", "prev_entry", "Previous Entry"),
Binding("ctrl+r", "rename_entry", "Rename Entry"),
Binding("escape", "back_to_list", "Back to List"),
Binding("f8", "toggle_sidebar", "Toggle Sidebar"),
Binding("f9", "toggle_focus", "Focus Sidebar/Editor"),
]
def __init__(self, diary_id: int = 1):
print("DEBUG: EditEntryScreen INIT")
super().__init__()
self.diary_id = diary_id
self.diary_name = f"Diary {diary_id}"
self.current_entry_index = 0
self.entries: List[Entry] = []
self.is_new_entry = False
self.has_unsaved_changes = False
self.new_entry_content = ""
self.new_entry_title = "New Entry"
self.next_entry_id = 1
self._updating_display = False
self._original_content = ""
self.is_refreshing = False
self.sidebar_visible = False
self.sidebar_focused = False
self._sidebar_opened_once = False
# Main header
self.header = Header(name="Pilgrim v6", classes="EditEntryScreen-header")
# Sub-header widgets
self.diary_info = Static(f"Diary: {self.diary_name}", id="diary_info", classes="EditEntryScreen-diary-info")
self.entry_info = Static("Loading...", id="entry_info", classes="EditEntryScreen-entry-info")
self.status_indicator = Static("Saved", id="status_indicator", classes="EditEntryScreen-status-indicator")
# Sub-header container
self.sub_header = Horizontal(
self.diary_info,
Static(classes="spacer EditEntryScreen-spacer"),
self.entry_info,
self.status_indicator,
id="sub_header",
classes="EditEntryScreen-sub-header"
)
# Text area
self.text_entry = TextArea(id="text_entry", classes="EditEntryScreen-text-entry")
# Sidebar widgets
self.sidebar_title = Static("📸 Photos", classes="EditEntryScreen-sidebar-title")
self.photo_list = OptionList(id="photo_list", classes="EditEntryScreen-sidebar-photo-list")
self.photo_info = Static("", classes="EditEntryScreen-sidebar-photo-info")
self.help_text = Static("", classes="EditEntryScreen-sidebar-help")
# Sidebar container: photo list and info in a flexible container, help_text fixed at bottom
self.sidebar_content = Vertical(
self.photo_list,
self.photo_info,
id="sidebar_content",
classes="EditEntryScreen-sidebar-content"
)
self.sidebar = Vertical(
self.sidebar_title,
self.sidebar_content,
self.help_text, # Always at the bottom, never scrolls
id="sidebar",
classes="EditEntryScreen-sidebar"
)
# Main container
self.main = Container(
self.sub_header,
self.text_entry,
id="EditEntryScreen_MainContainer",
classes="EditEntryScreen-main-container"
)
# Footer
self.footer = Footer(classes="EditEntryScreen-footer")
def _update_footer_context(self):
"""Forces footer refresh to show updated bindings"""
self.refresh()
def compose(self) -> ComposeResult:
print("DEBUG: EditEntryScreen COMPOSE", getattr(self, 'sidebar_visible', None))
yield self.header
yield Horizontal(
self.main,
self.sidebar,
id="content_container",
classes="EditEntryScreen-content-container"
)
yield self.footer
def on_mount(self) -> None:
"""Called when the screen is mounted"""
self.sidebar.display = False
self.sidebar_visible = False
# First update diary info, then refresh entries
self.update_diary_info()
self.refresh_entries()
# Initialize footer with editor context
self._update_footer_context()
def update_diary_info(self):
"""Updates diary information"""
try:
service_manager = self.app.service_manager
travel_diary_service = service_manager.get_travel_diary_service()
diary = travel_diary_service.read_by_id(self.diary_id)
if diary:
self.diary_name = diary.name
self.diary_info.update(f"Diary: {self.diary_name}")
else:
self.diary_name = f"Diary {self.diary_id}"
self.diary_info.update(f"Diary: {self.diary_name}")
self.notify(f"Diary {self.diary_id} not found, using default name")
except Exception as e:
self.diary_name = f"Diary {self.diary_id}"
self.diary_info.update(f"Diary: {self.diary_name}")
self.notify(f"Error loading diary info: {str(e)}")
self._ensure_diary_info_updated()
def _ensure_diary_info_updated(self):
"""Ensures the diary info widget is always updated with current diary name"""
try:
self.diary_info.update(f"Diary: {self.diary_name}")
except Exception as e:
self.diary_info.update(f"Diary: {self.diary_id}")
def refresh_entries(self):
"""Synchronous version of refresh"""
try:
service_manager = self.app.service_manager
entry_service = service_manager.get_entry_service()
all_entries = entry_service.read_all()
self.entries = [entry for entry in all_entries if entry.fk_travel_diary_id == self.diary_id]
self.entries.sort(key=lambda x: x.id)
if self.entries:
self.next_entry_id = max(entry.id for entry in self.entries) + 1
else:
self.next_entry_id = 1
self._update_entry_display()
self._update_sub_header()
except Exception as e:
self.notify(f"Error loading entries: {str(e)}")
self._ensure_diary_info_updated()
def _update_status_indicator(self, text: str, css_class: str):
"""Helper to update status indicator text and class."""
self.status_indicator.update(text)
self.status_indicator.remove_class("saved", "not-saved", "new", "read-only")
self.status_indicator.add_class(css_class)
def _update_sub_header(self):
"""Updates the sub-header with current entry information."""
if not self.entries and not self.is_new_entry:
self.entry_info.update("No entries")
self._update_status_indicator("Saved", "saved")
return
if self.is_new_entry:
self.entry_info.update(f"New Entry: {self.new_entry_title}")
if self.has_unsaved_changes:
self._update_status_indicator("Not Saved", "not-saved")
else:
self._update_status_indicator("New", "new")
else:
current_entry = self.entries[self.current_entry_index]
entry_text = f"Entry: \\[{self.current_entry_index + 1}/{len(self.entries)}] {current_entry.title}"
self.entry_info.update(entry_text)
self._update_status_indicator("Saved", "saved")
def _save_current_state(self):
"""Saves the current state before navigating"""
if self.is_new_entry:
self.new_entry_content = self.text_entry.text
elif self.entries and self.has_unsaved_changes:
current_entry = self.entries[self.current_entry_index]
current_entry.text = self.text_entry.text
def _finish_display_update(self):
"""Finishes the display update by reactivating change detection"""
self._updating_display = False
self._update_sub_header()
if self.sidebar_visible:
self._update_sidebar_content()
def _update_entry_display(self):
"""Updates the display of the current entry"""
if not self.entries and not self.is_new_entry:
self.text_entry.text = f"No entries found for diary '{self.diary_name}'\n\nPress Ctrl+N to create a new entry."
self.text_entry.read_only = True
self._original_content = self.text_entry.text
self._update_sub_header()
return
self._updating_display = True
if self.is_new_entry:
self.text_entry.text = self.new_entry_content
self.text_entry.read_only = False
self._original_content = self.new_entry_content
self.has_unsaved_changes = False
else:
current_entry = self.entries[self.current_entry_index]
self.text_entry.text = current_entry.text
self.text_entry.read_only = False
self._original_content = current_entry.text
self.has_unsaved_changes = False
self.call_after_refresh(self._finish_display_update)
def _update_sidebar_content(self):
"""Updates the sidebar content with photos for the current diary"""
photos = self._load_photos_for_diary(self.diary_id)
# Clear existing options safely
self.photo_list.clear_options()
# Add 'Ingest Photo' option at the top
self.photo_list.add_option("➕ Ingest Photo")
if not photos:
self.photo_info.update("No photos found for this diary")
self.help_text.update("📸 No photos available\n\nUse Photo Manager to add photos")
return
# Add photos to the list
for photo in photos:
self.photo_list.add_option(f"📷 {photo.name}")
self.photo_info.update(f"📸 {len(photos)} photos in diary")
# English, visually distinct help text
help_text = (
"[b]⌨️ Sidebar Shortcuts[/b]\n"
"[b][green]i[/green][/b]: Insert photo into entry\n"
"[b][green]n[/green][/b]: Add new photo\n"
"[b][green]d[/green][/b]: Delete selected photo\n"
"[b][green]e[/green][/b]: Edit selected photo\n"
"[b][yellow]Tab[/yellow][/b]: Back to editor\n"
"[b][yellow]F8[/yellow][/b]: Show/hide sidebar\n"
"[b][yellow]F9[/yellow][/b]: Switch focus (if needed)"
)
self.help_text.update(help_text)
def _load_photos_for_diary(self, diary_id: int) -> List[Photo]:
"""Loads all photos for the specific diary"""
try:
service_manager = self.app.service_manager
photo_service = service_manager.get_photo_service()
all_photos = photo_service.read_all()
photos = [photo for photo in all_photos if photo.fk_travel_diary_id == diary_id]
photos.sort(key=lambda x: x.id)
return photos
except Exception as e:
self.notify(f"Error loading photos: {str(e)}")
return []
def action_toggle_sidebar(self):
"""Toggles the sidebar visibility"""
print("DEBUG: TOGGLE SIDEBAR", self.sidebar_visible)
self.sidebar_visible = not self.sidebar_visible
if self.sidebar_visible:
self.sidebar.display = True
self._update_sidebar_content()
# Automatically focus the sidebar when opening
self.sidebar_focused = True
self.photo_list.focus()
# Notification when opening the sidebar for the first time
if not self._sidebar_opened_once:
self.notify(
"Sidebar opened and focused! Use the shortcuts shown in the help panel.",
severity="info"
)
self._sidebar_opened_once = True
else:
self.sidebar.display = False
self.sidebar_focused = False # Reset focus when hiding
self.text_entry.focus() # Return focus to editor
# Update footer after context change
self._update_footer_context()
self.refresh(layout=True)
def action_toggle_focus(self):
"""Toggles focus between editor and sidebar"""
print("DEBUG: TOGGLE FOCUS", self.sidebar_visible, self.sidebar_focused)
if not self.sidebar_visible:
# If sidebar is not visible, show it and focus it
self.action_toggle_sidebar()
return
self.sidebar_focused = not self.sidebar_focused
if self.sidebar_focused:
self.photo_list.focus()
else:
self.text_entry.focus()
# Update footer after focus change
self._update_footer_context()
def action_insert_photo(self):
"""Insert selected photo into text"""
if not self.sidebar_focused or not self.sidebar_visible:
self.notify("Use F8 to open the sidebar first.", severity="warning")
return
# Get selected photo
if self.photo_list.highlighted is None:
self.notify("No photo selected", severity="warning")
return
# Adjust index because of 'Ingest Photo' at the top
photo_index = self.photo_list.highlighted - 1
photos = self._load_photos_for_diary(self.diary_id)
if photo_index < 0 or photo_index >= len(photos):
self.notify("No photo selected", severity="warning")
return
selected_photo = photos[photo_index]
# Insert photo reference into text
photo_ref = f"\n[📷 {selected_photo.name}]({selected_photo.filepath})\n"
if selected_photo.caption:
photo_ref += f"*{selected_photo.caption}*\n"
# Insert at cursor position or at end
current_text = self.text_entry.text
cursor_position = len(current_text) # Insert at end for now
new_text = current_text + photo_ref
self.text_entry.text = new_text
self.notify(f"Inserted photo: {selected_photo.name}")
def action_ingest_new_photo(self):
"""Ingest a new photo using modal"""
if not self.sidebar_focused or not self.sidebar_visible:
self.notify("Use F8 to open the sidebar first.", severity="warning")
return
# Open add photo modal
self.app.push_screen(
AddPhotoModal(diary_id=self.diary_id),
self.handle_add_photo_result
)
def handle_add_photo_result(self, result: dict | None) -> None:
"""Callback that processes the add photo modal result."""
if result is None:
self.notify("Add photo cancelled")
return
# Photo was already created in the modal, just refresh the sidebar
if self.sidebar_visible:
self._update_sidebar_content()
self.notify(f"Photo '{result['name']}' added successfully!")
async def _async_create_photo(self, photo_data: dict):
"""Creates a new photo asynchronously"""
try:
service_manager = self.app.service_manager
photo_service = service_manager.get_photo_service()
current_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
new_photo = photo_service.create(
filepath=Path(photo_data["filepath"]),
name=photo_data["name"],
travel_diary_id=self.diary_id,
addition_date=current_date,
caption=photo_data["caption"]
)
if new_photo:
self.notify(f"Photo '{new_photo.name}' added successfully!")
# Refresh sidebar content
if self.sidebar_visible:
self._update_sidebar_content()
else:
self.notify("Error creating photo")
except Exception as e:
self.notify(f"Error creating photo: {str(e)}")
def action_delete_photo(self):
"""Delete selected photo"""
if not self.sidebar_focused or not self.sidebar_visible:
self.notify("Use F8 to open the sidebar first.", severity="warning")
return
if self.photo_list.highlighted is None:
self.notify("No photo selected", severity="warning")
return
# Adjust index because of 'Ingest Photo' at the top
photo_index = self.photo_list.highlighted - 1
photos = self._load_photos_for_diary(self.diary_id)
if photo_index < 0 or photo_index >= len(photos):
self.notify("No photo selected", severity="warning")
return
selected_photo = photos[photo_index]
# Open confirm delete modal
self.app.push_screen(
ConfirmDeleteModal(photo=selected_photo),
self.handle_delete_photo_result
)
def handle_delete_photo_result(self, result: bool) -> None:
"""Callback that processes the delete photo modal result."""
if result:
# Get the selected photo with adjusted index
photos = self._load_photos_for_diary(self.diary_id)
photo_index = self.photo_list.highlighted - 1 # Adjust for 'Ingest Photo' at top
if self.photo_list.highlighted is None or photo_index < 0 or photo_index >= len(photos):
self.notify("Photo no longer available", severity="error")
return
selected_photo = photos[photo_index]
# Schedule async deletion
self.call_later(self._async_delete_photo, selected_photo)
else:
self.notify("Delete cancelled")
async def _async_delete_photo(self, photo: Photo):
"""Deletes a photo asynchronously"""
try:
service_manager = self.app.service_manager
photo_service = service_manager.get_photo_service()
result = photo_service.delete(photo)
if result:
self.notify(f"Photo '{photo.name}' deleted successfully!")
# Refresh sidebar content
if self.sidebar_visible:
self._update_sidebar_content()
else:
self.notify("Error deleting photo")
except Exception as e:
self.notify(f"Error deleting photo: {str(e)}")
def action_edit_photo(self):
"""Edit selected photo using modal"""
if not self.sidebar_focused or not self.sidebar_visible:
self.notify("Use F8 to open the sidebar first.", severity="warning")
return
if self.photo_list.highlighted is None:
self.notify("No photo selected", severity="warning")
return
# Adjust index because of 'Ingest Photo' at the top
photo_index = self.photo_list.highlighted - 1
photos = self._load_photos_for_diary(self.diary_id)
if photo_index < 0 or photo_index >= len(photos):
self.notify("No photo selected", severity="warning")
return
selected_photo = photos[photo_index]
# Open edit photo modal
self.app.push_screen(
EditPhotoModal(photo=selected_photo),
self.handle_edit_photo_result
)
def handle_edit_photo_result(self, result: dict | None) -> None:
"""Callback that processes the edit photo modal result."""
if result is None:
self.notify("Edit photo cancelled")
return
# Get the selected photo with adjusted index
photos = self._load_photos_for_diary(self.diary_id)
photo_index = self.photo_list.highlighted - 1 # Adjust for 'Ingest Photo' at top
if self.photo_list.highlighted is None or photo_index < 0 or photo_index >= len(photos):
self.notify("Photo no longer available", severity="error")
return
selected_photo = photos[photo_index]
# Schedule async update
self.call_later(self._async_update_photo, selected_photo, result)
async def _async_update_photo(self, original_photo: Photo, photo_data: dict):
"""Updates a photo asynchronously"""
try:
service_manager = self.app.service_manager
photo_service = service_manager.get_photo_service()
# Create updated photo object
updated_photo = Photo(
filepath=photo_data["filepath"],
name=photo_data["name"],
addition_date=original_photo.addition_date,
caption=photo_data["caption"],
entries=original_photo.entries if original_photo.entries is not None else [],
id=original_photo.id
)
result = photo_service.update(original_photo, updated_photo)
if result:
self.notify(f"Photo '{updated_photo.name}' updated successfully!")
# Refresh sidebar content
if self.sidebar_visible:
self._update_sidebar_content()
else:
self.notify("Error updating photo")
except Exception as e:
self.notify(f"Error updating photo: {str(e)}")
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
"""Handles photo selection in the sidebar"""
if not self.sidebar_visible:
return
# If 'Ingest Photo' is selected (always index 0)
if event.option_index == 0:
self.action_ingest_new_photo()
return
photos = self._load_photos_for_diary(self.diary_id)
# Adjust index because of 'Ingest Photo' at the top
photo_index = event.option_index - 1
if not photos or photo_index >= len(photos):
return
selected_photo = photos[photo_index]
self.notify(f"Selected photo: {selected_photo.name}")
# Update photo info with details
photo_details = f"📷 {selected_photo.name}\n"
photo_details += f"📅 {selected_photo.addition_date}\n"
if selected_photo.caption:
photo_details += f"💬 {selected_photo.caption}\n"
photo_details += f"📁 {selected_photo.filepath}"
self.photo_info.update(photo_details)
def on_text_area_changed(self, event) -> None:
"""Detects text changes to mark as unsaved"""
if (hasattr(self, 'text_entry') and not self.text_entry.read_only and
not getattr(self, '_updating_display', False) and hasattr(self, '_original_content')):
current_content = self.text_entry.text
if current_content != self._original_content:
if not self.has_unsaved_changes:
self.has_unsaved_changes = True
self._update_sub_header()
else:
if self.has_unsaved_changes:
self.has_unsaved_changes = False
self._update_sub_header()
def on_focus(self, event) -> None:
"""Captures focus changes to update footer"""
# Check if focus changed to/from sidebar
if hasattr(event.widget, 'id'):
if event.widget.id == "photo_list":
self.sidebar_focused = True
self._update_footer_context()
elif event.widget.id == "text_entry":
self.sidebar_focused = False
self._update_footer_context()
def action_back_to_list(self) -> None:
"""Goes back to the diary list"""
if self.is_new_entry and not self.text_entry.text.strip() and not self.has_unsaved_changes:
self.app.pop_screen()
self.notify("Returned to diary list")
return
if self.has_unsaved_changes or (self.is_new_entry and self.text_entry.text.strip()):
self.notify("There are unsaved changes! Use Ctrl+S to save before leaving.")
return
self.app.pop_screen()
self.notify("Returned to diary list")
def action_next_entry(self) -> None:
"""Goes to the next entry"""
self._save_current_state()
if not self.entries:
if not self.is_new_entry:
self.is_new_entry = True
self._update_entry_display()
self.notify("New entry created")
else:
self.notify("Already in a new entry")
return
if self.is_new_entry:
self.notify("Already at the last position (new entry)")
elif self.current_entry_index < len(self.entries) - 1:
self.current_entry_index += 1
self._update_entry_display()
current_entry = self.entries[self.current_entry_index]
self.notify(f"Navigating to: {current_entry.title}")
else:
self.is_new_entry = True
self._update_entry_display()
self.notify("New entry created")
def action_prev_entry(self) -> None:
"""Goes to the previous entry"""
self._save_current_state()
if not self.entries:
self.notify("No entries to navigate")
return
if self.is_new_entry:
if self.entries:
self.is_new_entry = False
self.current_entry_index = len(self.entries) - 1
self._update_entry_display()
current_entry = self.entries[self.current_entry_index]
self.notify(f"Navigating to: {current_entry.title}")
else:
self.notify("No previous entries")
elif self.current_entry_index > 0:
self.current_entry_index -= 1
self._update_entry_display()
current_entry = self.entries[self.current_entry_index]
self.notify(f"Navigating to: {current_entry.title}")
else:
self.notify("Already at the first entry")
def action_rename_entry(self) -> None:
"""Opens a modal to rename the entry."""
if not self.entries and not self.is_new_entry:
self.notify("No entry to rename", severity="warning")
return
if self.is_new_entry:
current_name = self.new_entry_title
else:
current_entry = self.entries[self.current_entry_index]
current_name = current_entry.title
self.app.push_screen(
RenameEntryModal(current_name=current_name),
self.handle_rename_result
)
def handle_rename_result(self, new_name: str | None) -> None:
"""Callback that processes the rename modal result."""
if new_name is None:
self.notify("Rename cancelled")
return
if not new_name.strip():
self.notify("Name cannot be empty", severity="error")
return
if self.is_new_entry:
old_name = self.new_entry_title
self.new_entry_title = new_name
self.notify(f"New entry title changed to '{new_name}'")
else:
current_entry = self.entries[self.current_entry_index]
old_name = current_entry.title
current_entry.title = new_name
self.notify(f"Title changed from '{old_name}' to '{new_name}'")
self.has_unsaved_changes = True
self._update_sub_header()
def action_save(self) -> None:
"""Saves the current entry"""
if self.is_new_entry:
content = self.text_entry.text.strip()
if not content:
self.notify("Empty entry cannot be saved")
return
# Schedule async creation
self.call_later(self._async_create_entry, content)
else:
# Schedule async update
self.call_later(self._async_update_entry)
async def _async_create_entry(self, content: str):
"""Creates a new entry asynchronously"""
try:
service_manager = self.app.service_manager
entry_service = service_manager.get_entry_service()
current_date = datetime.now()
new_entry = entry_service.create(
travel_diary_id=self.diary_id,
title=self.new_entry_title,
text=content,
date=current_date
)
if new_entry:
self.entries.append(new_entry)
self.entries.sort(key=lambda x: x.id)
for i, entry in enumerate(self.entries):
if entry.id == new_entry.id:
self.current_entry_index = i
break
self.is_new_entry = False
self.has_unsaved_changes = False
self._original_content = new_entry.text
self.new_entry_title = "New Entry"
self.next_entry_id = max(entry.id for entry in self.entries) + 1
self._update_entry_display()
self.notify(f"New entry '{new_entry.title}' saved successfully!")
else:
self.notify("Error creating entry")
except Exception as e:
self.notify(f"Error creating entry: {str(e)}")
async def _async_update_entry(self):
"""Updates the current entry asynchronously"""
try:
if not self.entries:
self.notify("No entry to update")
return
current_entry = self.entries[self.current_entry_index]
updated_content = self.text_entry.text
updated_entry = Entry(
title=current_entry.title,
text=updated_content,
date=current_entry.date,
travel_diary_id=current_entry.fk_travel_diary_id,
id=current_entry.id
)
service_manager = self.app.service_manager
entry_service = service_manager.get_entry_service()
result = entry_service.update(current_entry, updated_entry)
if result:
current_entry.text = updated_content
self.has_unsaved_changes = False
self._original_content = updated_content
self._update_sub_header()
self.notify(f"Entry '{current_entry.title}' saved successfully!")
else:
self.notify("Error updating entry")
except Exception as e:
self.notify(f"Error updating entry: {str(e)}")
def on_key(self, event):
# Sidebar contextual shortcuts
if self.sidebar_focused and self.sidebar_visible:
if event.key == "i":
self.action_insert_photo()
event.stop()
elif event.key == "n":
self.action_ingest_new_photo()
event.stop()
elif event.key == "d":
self.action_delete_photo()
event.stop()
elif event.key == "e":
self.action_edit_photo()
event.stop()
# Shift+Tab: remove indent
elif self.focused is self.text_entry and event.key == "shift+tab":
textarea = self.text_entry
row, col = textarea.cursor_location
lines = textarea.text.splitlines()
if row < len(lines):
line = lines[row]
if line.startswith('\t'):
lines[row] = line[1:]
textarea.text = '\n'.join(lines)
textarea.cursor_location = (row, max(col - 1, 0))
elif line.startswith(' '): # 4 spaces
lines[row] = line[4:]
textarea.text = '\n'.join(lines)
textarea.cursor_location = (row, max(col - 4, 0))
elif line.startswith(' '):
n = len(line) - len(line.lstrip(' '))
to_remove = min(n, 4)
lines[row] = line[to_remove:]
textarea.text = '\n'.join(lines)
textarea.cursor_location = (row, max(col - to_remove, 0))
event.stop()
# Tab: insert tab
elif self.focused is self.text_entry and event.key == "tab":
self.text_entry.insert('\t')
event.stop()
@@ -1,105 +0,0 @@
import os
from pathlib import Path
from textual.app import ComposeResult
from textual.screen import Screen
from textual.widgets import Static, Input, Button
from textual.containers import Horizontal, Container
from .file_picker_modal import FilePickerModal
class AddPhotoModal(Screen):
"""Modal for adding a new photo"""
def __init__(self, diary_id: int):
super().__init__()
self.diary_id = diary_id
self.result = None
def compose(self) -> ComposeResult:
yield Container(
Static("📷 Add New Photo", classes="AddPhotoModal-Title"),
Static("File path:", classes="AddPhotoModal-Label"),
Horizontal(
Input(placeholder="Enter file path...", id="filepath-input", classes="AddPhotoModal-Input"),
Button("Escolher arquivo...", id="choose-file-button", classes="AddPhotoModal-Button"),
classes="AddPhotoModal-FileRow"
),
Static("Photo name:", classes="AddPhotoModal-Label"),
Input(placeholder="Enter photo name...", id="name-input", classes="AddPhotoModal-Input"),
Static("Caption (optional):", classes="AddPhotoModal-Label"),
Input(placeholder="Enter caption...", id="caption-input", classes="AddPhotoModal-Input"),
Horizontal(
Button("Add Photo", id="add-button", classes="AddPhotoModal-Button"),
Button("Cancel", id="cancel-button", classes="AddPhotoModal-Button"),
classes="AddPhotoModal-Buttons"
),
classes="AddPhotoModal-Dialog"
)
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "choose-file-button":
self.app.push_screen(
FilePickerModal(),
self.handle_file_picker_result
)
return
if event.button.id == "add-button":
filepath = self.query_one("#filepath-input", Input).value
name = self.query_one("#name-input", Input).value
caption = self.query_one("#caption-input", Input).value
if not filepath.strip() or not name.strip():
self.notify("File path and name are required", severity="error")
return
# Try to create the photo in the database
self.call_later(self._async_create_photo, {
"filepath": filepath.strip(),
"name": name.strip(),
"caption": caption.strip() if caption.strip() else None
})
elif event.button.id == "cancel-button":
self.dismiss()
async def _async_create_photo(self, photo_data: dict):
"""Creates a new photo asynchronously using PhotoService"""
try:
service_manager = self.app.service_manager
photo_service = service_manager.get_photo_service()
new_photo = photo_service.create(
filepath=Path(photo_data["filepath"]),
name=photo_data["name"],
travel_diary_id=self.diary_id,
caption=photo_data["caption"]
)
if new_photo:
self.notify(f"Photo '{new_photo.name}' added successfully!")
# Return the created photo data to the calling screen
self.result = {
"filepath": photo_data["filepath"],
"name": photo_data["name"],
"caption": photo_data["caption"],
"photo_id": new_photo.id
}
self.dismiss(self.result)
else:
self.notify("Error creating photo in database", severity="error")
except Exception as e:
self.notify(f"Error creating photo: {str(e)}", severity="error")
def handle_file_picker_result(self, result: str | None) -> None:
if result:
# Set the filepath input value
filepath_input = self.query_one("#filepath-input", Input)
filepath_input.value = result
# Trigger the input change event to update the UI
filepath_input.refresh()
# Auto-fill the name field with the filename (without extension)
filename = Path(result).stem
name_input = self.query_one("#name-input", Input)
if not name_input.value.strip():
name_input.value = filename
name_input.refresh()
else:
# User cancelled the file picker
self.notify("File selection cancelled", severity="information")
@@ -1,32 +0,0 @@
from textual.app import ComposeResult
from textual.screen import Screen
from textual.widgets import Static, Button
from textual.containers import Container, Horizontal
from pilgrim.models.photo import Photo
class ConfirmDeleteModal(Screen):
"""Modal for confirming photo deletion"""
def __init__(self, photo: Photo):
super().__init__()
self.photo = photo
self.result = None
def compose(self) -> ComposeResult:
yield Container(
Static("🗑️ Confirm Deletion", classes="ConfirmDeleteModal-Title"),
Static(f"Are you sure you want to delete the photo '{self.photo.name}'?", classes="ConfirmDeleteModal-Message"),
Static("This action cannot be undone.", classes="ConfirmDeleteModal-Warning"),
Horizontal(
Button("Delete", variant="error", id="delete-button", classes="ConfirmDeleteModal-Button"),
Button("Cancel", variant="default", id="cancel-button", classes="ConfirmDeleteModal-Button"),
classes="ConfirmDeleteModal-Buttons"
),
classes="ConfirmDeleteModal-Dialog"
)
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "delete-button":
self.result = True
self.dismiss(True)
elif event.button.id == "cancel-button":
self.dismiss(False)
@@ -1,68 +0,0 @@
from textual.app import ComposeResult
from textual.screen import Screen
from textual.widgets import Static, Input, Button
from textual.containers import Container, Horizontal
from pilgrim.models.photo import Photo
class EditPhotoModal(Screen):
"""Modal for editing an existing photo (name and caption only)"""
def __init__(self, photo: Photo):
super().__init__()
self.photo = photo
self.result = None
def compose(self) -> ComposeResult:
yield Container(
Static("✏️ Edit Photo", classes="EditPhotoModal-Title"),
Static("File path (read-only):", classes="EditPhotoModal-Label"),
Input(
value=self.photo.filepath,
id="filepath-input",
classes="EditPhotoModal-Input",
disabled=True
),
Static("Photo name:", classes="EditPhotoModal-Label"),
Input(
value=self.photo.name,
placeholder="Enter photo name...",
id="name-input",
classes="EditPhotoModal-Input"
),
Static("Caption (optional):", classes="EditPhotoModal-Label"),
Input(
value=self.photo.caption or "",
placeholder="Enter caption...",
id="caption-input",
classes="EditPhotoModal-Input"
),
Horizontal(
Button("Save Changes", id="save-button", classes="EditPhotoModal-Button"),
Button("Cancel", id="cancel-button", classes="EditPhotoModal-Button"),
classes="EditPhotoModal-Buttons"
),
classes="EditPhotoModal-Dialog"
)
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "save-button":
name = self.query_one("#name-input", Input).value
caption = self.query_one("#caption-input", Input).value
if not name.strip():
self.notify("Photo name is required", severity="error")
return
# Return the updated photo data
self.result = {
"filepath": self.photo.filepath, # Keep original filepath
"name": name.strip(),
"caption": caption.strip() if caption.strip() else None
}
self.dismiss(self.result)
elif event.button.id == "cancel-button":
self.dismiss()
def on_mount(self) -> None:
"""Focus on the name input when modal opens"""
self.query_one("#name-input", Input).focus()
@@ -1,66 +0,0 @@
import os
from pathlib import Path
from typing import Iterable
from textual.app import ComposeResult
from textual.screen import Screen
from textual.widgets import Static, DirectoryTree, Button
from textual.containers import Horizontal, Container
class ImageDirectoryTree(DirectoryTree):
"""DirectoryTree that only shows image files"""
def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]:
"""Filter to show only directories and image files"""
image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'}
return [
path for path in paths
if path.is_dir() or path.suffix.lower() in image_extensions
]
class FilePickerModal(Screen):
"""Modal for picking an image file using DirectoryTree"""
def __init__(self, start_path=None):
super().__init__()
self.start_path = Path(start_path or os.getcwd())
# Start one level up to make navigation easier
self.current_path = self.start_path.parent
def compose(self) -> ComposeResult:
yield Container(
Static(f"Current: {self.current_path}", id="title", classes="FilePickerModal-Title"),
ImageDirectoryTree(str(self.current_path), id="directory-tree"),
Horizontal(
Button("Up", id="up-button", classes="FilePickerModal-Button"),
Button("Cancel", id="cancel-button", classes="FilePickerModal-Button"),
classes="FilePickerModal-Buttons"
),
classes="FilePickerModal-Dialog"
)
def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected) -> None:
"""Handle file selection"""
file_path = event.path
# Check if it's an image file
image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'}
if file_path.suffix.lower() in image_extensions:
# Return the file path as result
self.dismiss(str(file_path))
else:
self.notify("Please select an image file", severity="warning")
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses"""
if event.button.id == "up-button":
# Navigate to parent directory
parent = self.current_path.parent
if parent != self.current_path:
self.current_path = parent
self.query_one("#title", Static).update(f"Current: {self.current_path}")
# Reload the directory tree
tree = self.query_one("#directory-tree", ImageDirectoryTree)
tree.path = str(self.current_path)
tree.reload()
elif event.button.id == "cancel-button":
# Return None to indicate cancellation
self.dismiss(None)
-43
View File
@@ -1,43 +0,0 @@
from textual.app import ComposeResult
from textual.containers import Vertical, Horizontal
from textual.screen import ModalScreen
from textual.widgets import Label, Input, Button
class NewDiaryModal(ModalScreen[str]):
BINDINGS = [
("escape", "cancel", "Cancel"),
]
def __init__(self):
super().__init__()
self.name_input = Input(id="NewDiaryModal-NameInput",classes="NewDiaryModal-NameInput") # This ID is fine, it's specific to the input
def compose(self) -> ComposeResult:
with Vertical(id="new_diary_dialog",classes="NewDiaryModal-Dialog"):
yield Label("Create a new diary", classes="NewDiaryModal-Title")
yield Label("Diary Name:")
yield self.name_input
with Horizontal(classes="NewDiaryModal-ButtonsContainer"):
yield Button("Create", variant="primary", id="create_diary_button",
classes="NewDiaryModal-CreateDiaryButton")
yield Button("Cancel", variant="default", id="cancel_button",
classes="NewDiaryModal-CancelButton")
def on_mount(self):
self.name_input.focus()
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handles button clicks."""
if event.button.id == "create_diary_button":
diary_name = self.name_input.value.strip()
if diary_name:
self.dismiss(diary_name)
else:
self.notify("Diary name cannot be empty.", severity="warning")
self.name_input.focus()
elif event.button.id == "cancel_button":
self.dismiss("")
def action_cancel(self) -> None:
"""Action to cancel the modal."""
self.dismiss("")
@@ -1,58 +0,0 @@
from textual.app import ComposeResult
from textual.containers import Vertical, Horizontal
from textual.screen import ModalScreen
from textual.widgets import Label, Input, Button
class RenameEntryModal(ModalScreen[str]):
"""A modal screen to rename a diary entry."""
BINDINGS = [
("escape", "cancel", "Cancel"),
]
def __init__(self, current_name: str):
super().__init__()
self._current_name = current_name
self.name_input = Input(
value=self._current_name,
placeholder="Type the new name...",
id="rename_input",
classes="RenameEntryModal-name-input"
)
def compose(self) -> ComposeResult:
with Vertical(id="rename_entry_dialog", classes="RenameEntryModal-dialog"):
yield Label("Rename Entry", classes="dialog-title RenameEntryModal-title")
yield Label("New Entry Title:", classes="RenameEntryModal-label")
yield self.name_input
with Horizontal(classes="dialog-buttons RenameEntryModal-buttons"):
yield Button("Save", variant="primary", id="save", classes="RenameEntryModal-save-button")
yield Button("Cancel", variant="default", id="cancel", classes="RenameEntryModal-cancel-button")
def on_mount(self) -> None:
"""Focuses on the input when the screen is mounted."""
self.name_input.focus()
self.name_input.cursor_position = len(self.name_input.value)
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handles button clicks."""
if event.button.id == "save":
new_name = self.name_input.value.strip()
if new_name:
self.dismiss(new_name) # Returns the new name
else:
self.dismiss(None) # Considers empty name as cancellation
else:
self.dismiss(None) # Returns None for cancellation
def on_input_submitted(self, event: Input.Submitted) -> None:
"""Allows saving by pressing Enter."""
new_name = event.value.strip()
if new_name:
self.dismiss(new_name)
else:
self.dismiss(None)
def action_cancel(self) -> None:
self.dismiss(None)
-629
View File
@@ -1,629 +0,0 @@
Screen {
layout: vertical;
background: $surface-darken-1;
align: center middle;
}
.EditEntryScreen-sub-header {
layout: horizontal;
background: $primary-darken-1;
height: 1;
padding: 0 1;
align: center middle;
width: 100%;
}
.EditEntryScreen-diary-info {
width: auto;
color: $text-muted;
margin-right: 2;
}
.EditEntryScreen-entry-info {
width: auto;
color: $text;
margin-right: 1;
}
.EditEntryScreen-spacer {
width: 1fr;
}
.EditEntryScreen-status-indicator {
width: auto;
padding: 0 1;
content-align: center middle;
}
.EditEntryScreen-status-indicator.saved {
background: #2E8B57; /* SeaGreen */
color: $text;
}
.EditEntryScreen-status-indicator.not-saved {
background: #B22222; /* FireBrick */
color: $text;
}
.EditEntryScreen-status-indicator.new {
background: #4682B4; /* SteelBlue */
color: $text;
}
.EditEntryScreen-status-indicator.read-only {
background: #696969; /* DimGray */
color: $text-muted;
}
.EditEntryScreen-main-container {
background: bisque;
margin: 0;
}
.EditEntryScreen-text-entry {
width: 100%;
height: 1fr;
border: none;
}
AboutScreen {
align: center middle;
}
#AboutScreen_AboutContainer {
align: center middle;
width: 85%;
height: 95%;
border: thick $primary;
background: $surface;
margin: 1;
}
#AboutScreen_AboutContainer TextArea {
align: center middle;
width: 100%;
height: 55%;
margin: 2;
padding: 1;
}
#AboutScreen_SubContainer {
align: center middle;
height: 45%;
padding: 1 1;
margin: 1 1;
}
#AboutScreen_AboutTitle {
color: $accent;
text-style: bold;
text-align: center;
margin-bottom: 1;
}
#AboutScreen_AboutVersion {
color: $warning;
text-style: italic;
text-align: center;
margin-bottom: 1;
}
#AboutScreen_AboutContent {
text-align: center;
margin-bottom: 1;
}
#AboutScreen_AboutContact,
#AboutScreen_AboutAuthor {
color: $text-muted;
text-align: center;
margin-bottom: 1;
}
#AboutScreen_AboutLicense {
height: 60%;
}
/* Main container - distributing vertical space */
.DiaryListScreen-DiaryListContainer {
height: 1fr;
width: 100%;
layout: vertical;
align: center top; /* Aligns at the top to follow the wireframe */
padding: 2 4; /* More space on the sides */
background: $surface; /* Removing aquamarine to keep it cleaner */
}
/* Diary list - main area as in the wireframe */
.DiaryListScreen-DiaryListOptions {
border: round $primary-lighten-2;
background: $surface;
width: 75%; /* Generous width as in the wireframe */
height: 60%; /* Occupies available space */
margin: 2 0; /* Vertical margin to separate from other elements */
}
.DiaryListScreen-DiaryListOptions:focus {
border: round $primary;
}
/* Button container - right below the list */
.DiaryListScreen-ButtonsGrid {
layout: horizontal;
align: center middle;
width: 75%; /* Same width as the diary list */
height: 20%; /* Increasing height to fit button text */
}
/* Individual buttons - ensuring space for text */
.DiaryListScreen-NewDiaryButton,
.DiaryListScreen-EditDiaryButton,
.DiaryListScreen-OpenDiaryButton {
margin: 0 1 1 0; /* Space between buttons */
height: 1fr;
width: 1fr;
border: round grey;
text-align: center;
content-align: center middle;
}
/* Tips - using the correct class */
.DiaryListScreen-DiaryListTips {
width: 100%; /* Full width when using dock */
height: auto;
text-style: italic;
text-align: center; /* Centers text within the element */
color: $text-muted;
dock: bottom; /* Sticks to the bottom of the screen */
margin: 0 1;
}
/* Generic styles that haven't changed */
.option-list--option-highlighted {
background: $primary-darken-2;
}
.option-list--option-selected {
text-style: bold;
background: $primary;
}
Header {
background: $primary;
color: $text;
}
Footer {
background: $primary;
color: $text;
}
Screen.-modal {
background: rgba(0, 0, 0, 0.7); /* Darkens the background screen underneath */
align: center middle; /* Centers the content (your modal) that is in this modal screen */
}
/* Style for the new diary modal dialog */
.NewDiaryModal-dialog {
layout: vertical;
width: 60%;
height: auto;
background: $surface;
border: thick $accent;
padding: 2 4;
align: center middle;
}
.NewDiaryModal-title {
text-align: center;
text-style: bold;
color: $primary;
margin-bottom: 1;
}
.NewDiaryModal-label {
margin-bottom: 1;
}
.NewDiaryModal-name-input {
width: 1fr;
margin-bottom: 2;
}
.NewDiaryModal-buttons {
width: 1fr;
height: auto;
align: center middle;
padding-top: 1;
}
.NewDiaryModal-buttons Button {
margin: 0 1;
width: 1fr;
}
/* Additional classes for NewDiaryModal */
.NewDiaryModal-Dialog {
layout: vertical;
width: 60%;
height: auto;
background: $surface;
border: thick $accent;
padding: 2 4;
align: center middle;
}
.NewDiaryModal-Title {
text-align: center;
text-style: bold;
color: $primary;
margin-bottom: 1;
}
.NewDiaryModal-NameInput {
width: 1fr;
margin-bottom: 2;
}
.NewDiaryModal-ButtonsContainer {
width: 1fr;
height: auto;
align: center middle;
padding-top: 1;
}
.NewDiaryModal-CreateDiaryButton,
.NewDiaryModal-CancelButton {
margin: 0 1;
width: 1fr;
}
/* Style for the edit diary modal dialog */
.EditDiaryModal-dialog {
layout: vertical;
width: 60%;
height: auto;
background: $surface;
border: thick $warning;
padding: 2 4;
align: center middle;
}
.EditDiaryModal-title {
text-align: center;
text-style: bold;
color: $warning;
margin-bottom: 1;
}
.EditDiaryModal-label {
margin-bottom: 1;
}
.EditDiaryModal-name-input {
width: 1fr;
margin-bottom: 2;
}
.EditDiaryModal-buttons {
width: 1fr;
height: auto;
align: center middle;
padding-top: 1;
}
.EditDiaryModal-buttons Button {
margin: 0 1;
width: 1fr;
}
/* Additional classes for EditDiaryModal */
.EditDiaryModal-Dialog {
layout: vertical;
width: 60%;
height: auto;
background: $surface;
border: thick $warning;
padding: 2 4;
align: center middle;
}
.EditDiaryModal-Title {
text-align: center;
text-style: bold;
color: $warning;
margin-bottom: 1;
}
.EditDiaryModal-NameInput {
width: 1fr;
margin-bottom: 2;
}
.EditDiaryModal-ButtonsContainer {
width: 1fr;
height: auto;
align: center middle;
padding-top: 1;
}
.EditDiaryModal-SaveButton,
.EditDiaryModal-CancelButton {
margin: 0 1;
width: 1fr;
}
/* Style for the rename entry modal dialog */
.RenameEntryModal-dialog {
layout: vertical;
width: 60%;
height: auto;
background: $surface;
border: thick $accent;
padding: 2 4;
align: center middle;
}
.RenameEntryModal-title {
text-align: center;
text-style: bold;
color: $accent;
margin-bottom: 1;
}
.RenameEntryModal-name-input {
width: 1fr;
margin-bottom: 2;
}
.RenameEntryModal-buttons {
width: 1fr;
height: auto;
align: center middle;
padding-top: 1;
}
.RenameEntryModal-save-button,
.RenameEntryModal-cancel-button {
margin: 0 1;
width: 1fr;
}
.EditEntryScreen-sidebar {
width: 40;
min-height: 10;
border-left: solid green;
padding: 1;
background: $surface-darken-2;
color: $primary;
text-style: bold;
content-align: left top;
}
.EditEntryScreen-content-container {
layout: horizontal;
height: 1fr;
}
.EditEntryScreen-sidebar-title {
text-align: center;
text-style: bold;
color: $accent;
padding: 1;
border-bottom: solid $accent;
margin-bottom: 1;
}
.EditEntryScreen-sidebar-content {
height: 1fr;
layout: vertical;
}
.EditEntryScreen-sidebar-photo-list {
height: 1fr;
border: solid $accent;
margin-bottom: 1;
}
.EditEntryScreen-sidebar-photo-info {
height: auto;
min-height: 3;
border: solid $warning;
padding: 1;
margin-bottom: 1;
background: $surface-darken-1;
}
.EditEntryScreen-sidebar-help {
height: auto;
min-height: 8;
border: solid $success;
padding: 1;
background: $surface-darken-1;
text-style: italic;
}
/* Photo Modal Styles */
.modal-dialog {
layout: vertical;
width: 60%;
height: auto;
background: $surface;
border: thick $accent;
padding: 2 4;
align: center middle;
}
.modal-title {
text-align: center;
text-style: bold;
color: $primary;
margin-bottom: 1;
}
.modal-label {
margin-bottom: 1;
color: $text;
}
.modal-input {
width: 1fr;
margin-bottom: 2;
}
.modal-buttons {
width: 1fr;
height: auto;
align: center middle;
padding-top: 1;
}
.modal-button {
margin: 0 1;
width: 1fr;
}
/* AddPhotoModal styles */
.AddPhotoModal-Dialog {
layout: vertical;
width: 60%;
height: auto;
background: $surface;
border: thick $accent;
padding: 2 4;
align: center middle;
}
.AddPhotoModal-Title {
text-align: center;
text-style: bold;
color: $primary;
margin-bottom: 1;
}
.AddPhotoModal-Label {
margin-bottom: 1;
color: $text;
}
.AddPhotoModal-Input {
width: 1fr;
margin-bottom: 2;
}
.AddPhotoModal-Buttons {
width: 1fr;
height: auto;
align: center middle;
padding-top: 1;
}
.AddPhotoModal-Button {
margin: 0 1;
width: 1fr;
}
/* EditPhotoModal styles */
.EditPhotoModal-Dialog {
layout: vertical;
width: 60%;
height: auto;
background: $surface;
border: thick $accent;
padding: 2 4;
align: center middle;
}
.EditPhotoModal-Title {
text-align: center;
text-style: bold;
color: $primary;
margin-bottom: 1;
}
.EditPhotoModal-Label {
margin-bottom: 1;
color: $text;
}
.EditPhotoModal-Input {
width: 1fr;
margin-bottom: 2;
}
.EditPhotoModal-Buttons {
width: 1fr;
height: auto;
align: center middle;
padding-top: 1;
}
.EditPhotoModal-Button {
margin: 0 1;
width: 1fr;
}
/* FilePickerModal styles */
.FilePickerModal-Dialog {
layout: vertical;
width: 80%;
height: 80%;
background: $surface;
border: thick $accent;
padding: 2 4;
align: center middle;
}
.FilePickerModal-Title {
text-align: center;
text-style: bold;
color: $primary;
margin-bottom: 1;
}
.FilePickerModal-Buttons {
width: 1fr;
height: auto;
align: center middle;
padding-top: 1;
}
.FilePickerModal-Button {
margin: 0 1;
width: 1fr;
}
/* DirectoryTree specific styles */
#directory-tree {
height: 1fr;
border: solid $accent;
margin: 1;
}
/* ConfirmDeleteModal styles */
.ConfirmDeleteModal-Dialog {
layout: vertical;
width: 60%;
height: auto;
background: $surface;
border: thick $error;
padding: 2 4;
align: center middle;
}
.ConfirmDeleteModal-Title {
text-align: center;
text-style: bold;
color: $error;
margin-bottom: 1;
}
.ConfirmDeleteModal-Message {
text-align: center;
color: $text;
margin-bottom: 1;
}
.ConfirmDeleteModal-Warning {
text-align: center;
color: $warning;
text-style: italic;
margin-bottom: 2;
}
.ConfirmDeleteModal-Buttons {
width: 1fr;
height: auto;
align: center middle;
padding-top: 1;
}
.ConfirmDeleteModal-Button {
margin: 0 1;
width: 1fr;
}
-58
View File
@@ -1,58 +0,0 @@
from pathlib import Path
from typing import Iterable
from textual.app import App, SystemCommand
from textual.screen import Screen
from pilgrim.service.servicemanager import ServiceManager
from pilgrim.ui.screens.about_screen import AboutScreen
from pilgrim.ui.screens.diary_list_screen import DiaryListScreen
from pilgrim.ui.screens.edit_entry_screen import EditEntryScreen
CSS_FILE_PATH = Path(__file__).parent / "styles" / "pilgrim.css"
class UIApp(App):
CSS_PATH = CSS_FILE_PATH
def __init__(self,service_manager: ServiceManager, **kwargs):
super().__init__(**kwargs)
self.service_manager = service_manager
def on_mount(self) -> None:
"""Called when the app starts. Loads the main screen."""
self.push_screen(DiaryListScreen())
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
"""Return commands based on current screen."""
# Commands for DiaryListScreen
if isinstance(screen, DiaryListScreen):
yield SystemCommand(
"About Pilgrim",
"Open About Pilgrim",
screen.action_about_cmd
)
elif isinstance(screen, AboutScreen):
yield SystemCommand(
"Back to List",
"Return to the diary list",
screen.dismiss
)
elif isinstance(screen, EditEntryScreen):
yield SystemCommand(
"Back to Diary List",
"Return to the diary list",
screen.action_back_to_list
)
# Always include quit command
yield SystemCommand(
"Quit Application",
"Exit Pilgrim",
self.action_quit
)
@@ -1,15 +1,14 @@
from datetime import datetime
from typing import List from typing import List
from ..models.entry import Entry from src.models.entry import Entry
from ..models.travel_diary import TravelDiary from src.models.travel_diary import TravelDiary
class EntryService: class EntryService:
def __init__(self,session): def __init__(self,session):
self.session = session self.session = session
def create(self, travel_diary_id:int, title: str, text: str, date: datetime, ): def create(self, travel_diary_id:int, title: str, text: str, date: str, ):
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
+48
View File
@@ -0,0 +1,48 @@
from pathlib import Path
from typing import List
from src.models.photo import Photo
from src.models.travel_diary import TravelDiary
class PhotoService:
def __init__(self, session):
self.session = session
def create(self, filepath:Path, name:str, travel_diary_id, addition_date=None, caption=None, ) -> Photo | None:
travel_diary = self.session.query(TravelDiary).filter(TravelDiary.id == travel_diary_id).first()
if not travel_diary:
return None
new_photo = Photo(filepath, name, addition_date=addition_date, caption=caption)
self.session.add(new_photo)
self.session.commit()
self.session.refresh(new_photo)
return new_photo
def read_by_id(self, photo_id:int) -> Photo:
return self.session.query(Photo).get(photo_id)
def read_all(self) -> List[Photo]:
return self.session.query(Photo).all()
def update(self,photo_src:Photo,photo_dst:Photo) -> Photo | None:
original:Photo = self.read_by_id(photo_src.id)
if original:
original.filepath = photo_dst.filepath
original.name = photo_dst.name
original.addition_date = photo_dst.addition_date
original.caption = photo_dst.caption
original.entries.extend(photo_dst.entries)
self.session.commit()
self.session.refresh(original)
return original
return None
def delete(self, photo_src:Photo) -> Photo | None:
excluded = self.read_by_id(photo_src.id)
if excluded:
self.session.delete(excluded)
self.session.commit()
self.session.refresh(excluded)
return excluded
return None
@@ -1,6 +1,5 @@
from pilgrim.service.entry_service import EntryService from src.service.entry_service import EntryService
from pilgrim.service.photo_service import PhotoService from src.service.travel_diary_service import TravelDiaryService
from pilgrim.service.travel_diary_service import TravelDiaryService
class ServiceManager: class ServiceManager:
@@ -18,7 +17,3 @@ class ServiceManager:
if self.session is not None: if self.session is not None:
return TravelDiaryService(self.session) return TravelDiaryService(self.session)
return None return None
def get_photo_service(self):
if self.session is not None:
return PhotoService(self.session)
return None
@@ -1,10 +1,10 @@
from ..models.travel_diary import TravelDiary from src.models.travel_diary import TravelDiary
import asyncio
class TravelDiaryService: class TravelDiaryService:
def __init__(self,session): def __init__(self,session):
self.session = session self.session = session
async def async_create(self, name:str): def create(self, name:str):
new_travel_diary = TravelDiary(name) new_travel_diary = TravelDiary(name)
self.session.add(new_travel_diary) self.session.add(new_travel_diary)
self.session.commit() self.session.commit()
@@ -18,21 +18,18 @@ class TravelDiaryService:
def read_all(self): def read_all(self):
return self.session.query(TravelDiary).all() return self.session.query(TravelDiary).all()
def update(self, travel_diary_id: int, name: str): def update(self, travel_diary_src:TravelDiary,travel_diary_dst:TravelDiary):
original = self.read_by_id(travel_diary_id) original = self.read_by_id(travel_diary_src.id)
if original is not None: if original is not None:
original.name = name original.name = travel_diary_dst.name
self.session.commit() self.session.commit()
self.session.refresh(original) self.session.refresh(original)
return original return original
async def async_update(self, travel_diary_id: int, name: str): def delete(self, travel_diary_src:TravelDiary):
return self.update(travel_diary_id, name) excluded = self.read_by_id(travel_diary_src.id)
def delete(self, travel_diary_id: TravelDiary):
excluded = self.read_by_id(travel_diary_id.id)
if excluded is not None: if excluded is not None:
self.session.delete(travel_diary_id) self.session.delete(travel_diary_src)
self.session.commit() self.session.commit()
return excluded return excluded
return None return None