diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..b0c602a --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,163 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +*.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Flet +storage/ \ No newline at end of file diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..f56eb25 --- /dev/null +++ b/app/README.md @@ -0,0 +1,23 @@ +# Flet app + +Flet app using Flet extension. + +To run the app: + +1. Install dependencies from pyproject.toml: + +``` +poetry install +``` + +2. Build app: + +``` +poetry run flet build macos -v +``` + +3. Run app: + +``` +poetry run flet run +``` \ No newline at end of file diff --git a/app/pyproject.toml b/app/pyproject.toml new file mode 100644 index 0000000..61bda5f --- /dev/null +++ b/app/pyproject.toml @@ -0,0 +1,41 @@ +[project] +name = "songsheet_generator" +version = "0.1.0" +description = "" +readme = "README.md" +requires-python = ">=3.9" +authors = [ + { name = "Leopold Fajtak", email = "leopold@fajtak.at" } +] +dependencies = [ + "flet==0.26.0" +] + +[tool.flet] +# org name in reverse domain name notation, e.g. "com.mycompany". +# Combined with project.name to build bundle ID for iOS and Android apps +org = "at.fajtak.songsheet_generator" + +# project display name that is used as an app title on Android and iOS home screens, +# shown in window titles and about app dialogs on desktop. +product = "Songsheet Generator" + +# company name to display in about app dialogs +company = "Leopold Fajtak" + +# copyright text to display in about app dialogs +copyright = "Copyright (C) 2025 by Leopold Fajtak" + +[tool.flet.app] +path = "src/songsheet_generator" + +[tool.uv] +dev-dependencies = [ + "flet[all]==0.26.0", +] + +[tool.poetry] +package-mode = false + +[tool.poetry.group.dev.dependencies] +flet = {extras = ["all"], version = "0.26.0"} \ No newline at end of file diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..c230cff --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,2 @@ +flet[all] +dependency_injector \ No newline at end of file diff --git a/app/src/songsheet_generator/components/assets/icon.png b/app/src/songsheet_generator/components/assets/icon.png new file mode 100644 index 0000000..bebc6d5 Binary files /dev/null and b/app/src/songsheet_generator/components/assets/icon.png differ diff --git a/app/src/songsheet_generator/components/config_component.py b/app/src/songsheet_generator/components/config_component.py new file mode 100644 index 0000000..a9add84 --- /dev/null +++ b/app/src/songsheet_generator/components/config_component.py @@ -0,0 +1,24 @@ + +import flet as ft +from pathlib import PurePath +from songsheet_generator.service.config_service import ConfigService + +class ConfigComponent(ft.Column): + # application's root control is a Column containing all other controls + def __init__(self, config_service: ConfigService): + super().__init__() + self.config_service = config_service + self.path = ft.TextField(label="Bibliothek Pfad", value = self.config_service.library_path, on_change=self.textbox_changed) + self.width = 600 + self.controls = [ + self.path, + ft.ElevatedButton(text="Save config", on_click=self.save_config) + ] + + def textbox_changed(self, e): + self.config_service.library_path = e.control.value + + def save_config(self, e): + self.config_service.save_config_to_storage() + + diff --git a/app/src/songsheet_generator/components/song_component.py b/app/src/songsheet_generator/components/song_component.py new file mode 100644 index 0000000..1c45753 --- /dev/null +++ b/app/src/songsheet_generator/components/song_component.py @@ -0,0 +1,91 @@ +import flet as ft +from songsheet_generator.types.song_purpose import SongPurpose +from songsheet_generator.types.song import Song +from songsheet_generator.service.song_service import SongService +from pathlib import Path + +class SongComponent(ft.Column): + + def __init__(self, + song_service: SongService, + song_delete = lambda a : None, + song: Song = Song(), + ): + super().__init__() + self.song_service = song_service + self.song = song + self.delete = song_delete + self.display_song = ft.Row(spacing=0, controls=[ + ft.Text(self.song.purpose.display_name, weight=ft.FontWeight.BOLD), + ft.Text(self.song.path), + ]) + self.edit_purpose = ft.Dropdown( + label = "Messteil", + hint_text="Teil der Messe, der gesungen wird", + options=[ ft.dropdown.Option(key=purpose.key, text=purpose.display_name) for purpose in SongPurpose.get_all_purposes() ], + autofocus=True, + ) + self.edit_path = ft.Dropdown( + label="Pfad", + options = [ft.dropdown.Option(key=path) for path in self.song_service.all_songs()] + ) + + self.edit_name = ft.TextField(expand=1) + + self.display_view = ft.Row( + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + controls=[ + self.display_song, + ft.Row( + spacing=0, + controls=[ + ft.IconButton( + icon=ft.Icons.CREATE_OUTLINED, + tooltip="Lied bearbeiten", + on_click=self.edit_clicked, + ), + ft.IconButton( + ft.Icons.DELETE_OUTLINE, + tooltip="Lied entfernen", + on_click=self.delete_clicked, + ), + ], + ), + ], + ) + + self.edit_view = ft.Row( + visible=False, + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + controls=[ + self.edit_purpose, + self.edit_path, + ft.IconButton( + icon=ft.Icons.DONE_OUTLINE_OUTLINED, + icon_color=ft.Colors.GREEN, + tooltip="Update Song", + on_click=self.save_clicked, + ), + ], + ) + self.controls = [self.display_view, self.edit_view] + + def edit_clicked(self, e): + self.edit_purpose.value = self.song.purpose.key + self.edit_path.value = self.song.path + self.display_view.visible = False + self.edit_view.visible = True + self.update() + + def save_clicked(self, e): + self.song.purpose = SongPurpose(self.edit_purpose.value) + self.display_song.controls[0].value = self.song.purpose.display_name + self.display_song.controls[1].value = Path(self.song.path) + self.display_view.visible = True + self.edit_view.visible = False + self.update() + + def delete_clicked(self, e): + self.delete(self) \ No newline at end of file diff --git a/app/src/songsheet_generator/components/songs_component.py b/app/src/songsheet_generator/components/songs_component.py new file mode 100644 index 0000000..4f75312 --- /dev/null +++ b/app/src/songsheet_generator/components/songs_component.py @@ -0,0 +1,47 @@ + +import flet as ft +from pathlib import Path +from .song_component import SongComponent +from songsheet_generator.types.song_purpose import SongPurpose +from songsheet_generator.types.song import Song +from songsheet_generator.service.song_service import SongService +from songsheet_generator.containers import Services + +class SongsComponent(ft.Column): + # application's root control is a Column containing all other controls + def __init__(self, song_service: SongService): + super().__init__() + self.song_service = song_service + self.songs = ft.Column() + self.purpose = ft.Dropdown( + label = "Messteil", + hint_text="Teil der Messe, der gesungen wird", + options=[ ft.dropdown.Option(key=purpose.key, text=purpose.display_name) for purpose in SongPurpose.get_all_purposes() ], + autofocus=True, + ) + self.path = ft.Dropdown( + label="Pfad", + options = [ft.dropdown.Option(key=path) for path in self.song_service.all_songs()] + ) + self.width = 600 + self.controls = [ + ft.Row( + controls=[ + self.purpose, + self.path, + ft.FloatingActionButton( + icon=ft.Icons.ADD, on_click=self.add_clicked + ), + ], + ), + self.songs, + ] + + def add_clicked(self, s): + # todo use dependency injection and the factory design pattern + self.songs.controls.append(SongComponent(song_service = self.song_service, song_delete = self.song_delete, song = Song(SongPurpose(self.purpose.value), Path(self.path.value)))) + self.update() + + def song_delete(self, song): + self.songs.controls.remove(song) + self.update() diff --git a/app/src/songsheet_generator/containers.py b/app/src/songsheet_generator/containers.py new file mode 100644 index 0000000..d0b3dda --- /dev/null +++ b/app/src/songsheet_generator/containers.py @@ -0,0 +1,16 @@ +"""Containers module""" + +from dependency_injector import containers, providers +from service.config_service_impl import ConfigServiceImpl +from service.song_service_impl import SongServiceImpl +from components.song_component import SongComponent + +class Services(containers.DeclarativeContainer): + config_service = providers.Singleton(ConfigServiceImpl) + song_service = providers.Singleton(SongServiceImpl, config_service = config_service) + song_component_factory = providers.Factory(SongComponent, song_service=song_service) + +class Application(containers.DeclarativeContainer): + services = providers.Container( + Services + ) \ No newline at end of file diff --git a/app/src/songsheet_generator/main.py b/app/src/songsheet_generator/main.py new file mode 100644 index 0000000..bf1d8bc --- /dev/null +++ b/app/src/songsheet_generator/main.py @@ -0,0 +1,48 @@ +import flet as ft +from dependency_injector.wiring import Provide, inject +from components.songs_component import SongsComponent +from components.config_component import ConfigComponent +from service.config_service import ConfigService +from service.song_service import SongService +from songsheet_generator.containers import Application + +def main( + page: ft.Page, + song_service: SongService = Provide[Application.services.song_service], + config_service: ConfigService = Provide[Application.services.config_service]): + + page.title = "Songsheet Generator" + page.horizontal_alignment = ft.CrossAxisAlignment.CENTER + page.update() + + config_service.set_page(page) + + lieder = SongsComponent(song_service) + config = ConfigComponent(config_service) + + tabs = ft.Tabs( + selected_index=1, + animation_duration=300, + tabs = [ + ft.Tab( + text="Lieder", + content=lieder, + ), + ft.Tab( + text="Events", + content=ft.Text("Events"), + ), + ft.Tab( + text="Config", + content=config, + ) + ] + ) + + #add application's root control to the page + page.add(tabs) + + +container1 = Application() +container1.wire(modules=[__name__]) +ft.app(main) diff --git a/app/src/songsheet_generator/service/config_service.py b/app/src/songsheet_generator/service/config_service.py new file mode 100644 index 0000000..d0b7b86 --- /dev/null +++ b/app/src/songsheet_generator/service/config_service.py @@ -0,0 +1,17 @@ +import flet as ft +from abc import ABC, abstractmethod +from pathlib import Path + +class ConfigService(ABC): + @property + @abstractmethod + def library_path(self) -> Path: + pass + + @abstractmethod + def set_page(self, page: ft.Page): + pass + + @abstractmethod + def save_config_to_storage(self): + pass \ No newline at end of file diff --git a/app/src/songsheet_generator/service/config_service_impl.py b/app/src/songsheet_generator/service/config_service_impl.py new file mode 100644 index 0000000..c5c5ae7 --- /dev/null +++ b/app/src/songsheet_generator/service/config_service_impl.py @@ -0,0 +1,26 @@ +import flet as ft +from .config_service import ConfigService +from pathlib import Path + +class ConfigServiceImpl(ConfigService): + + def __init__(self): + self.library_path: Path = Path.home() + self.page = None + + def set_page(self, page: ft.Page): + self.page = page + if self.page.client_storage.contains_key("library_path"): + self.library_path = Path(self.page.client_storage.get("library_path")) + + @property + def library_path(self) -> Path: + return self._library_path + + @library_path.setter + def library_path(self, value: Path): + self._library_path = value + + def save_config_to_storage(self): + if self.page: + self.page.client_storage.set("library_path", self.library_path) \ No newline at end of file diff --git a/app/src/songsheet_generator/service/library.py b/app/src/songsheet_generator/service/library.py new file mode 100644 index 0000000..f2324fc --- /dev/null +++ b/app/src/songsheet_generator/service/library.py @@ -0,0 +1,2 @@ +class Library(): + def __init__(self): diff --git a/app/src/songsheet_generator/service/song_service.py b/app/src/songsheet_generator/service/song_service.py new file mode 100644 index 0000000..5a0a58a --- /dev/null +++ b/app/src/songsheet_generator/service/song_service.py @@ -0,0 +1,8 @@ +from abc import ABC, abstractmethod + +class SongService(ABC): + + @abstractmethod + def all_songs(): + pass + \ No newline at end of file diff --git a/app/src/songsheet_generator/service/song_service_impl.py b/app/src/songsheet_generator/service/song_service_impl.py new file mode 100644 index 0000000..5e8655f --- /dev/null +++ b/app/src/songsheet_generator/service/song_service_impl.py @@ -0,0 +1,12 @@ +from .config_service import ConfigService +from .song_service import SongService + +class SongServiceImpl(SongService): + + def __init__(self, config_service: ConfigService): + self.config_service = config_service + + def all_songs(self): + assignments_paths = list(self.config_service.library_path.rglob('assignments.tt')) + return [ p.parent.relative_to(self.config_service.library_path) for p in assignments_paths ] + \ No newline at end of file diff --git a/app/src/songsheet_generator/types/song.py b/app/src/songsheet_generator/types/song.py new file mode 100644 index 0000000..2ea8470 --- /dev/null +++ b/app/src/songsheet_generator/types/song.py @@ -0,0 +1,9 @@ +from pathlib import PurePath +from .song_purpose import SongPurpose + +class Song(): + def __init__(self, + purpose: SongPurpose = SongPurpose(), + path: PurePath = PurePath(),): + self.purpose = purpose + self.path = path diff --git a/app/src/songsheet_generator/types/song_purpose.py b/app/src/songsheet_generator/types/song_purpose.py new file mode 100644 index 0000000..12ac27a --- /dev/null +++ b/app/src/songsheet_generator/types/song_purpose.py @@ -0,0 +1,27 @@ +class SongPurpose(): + all_purposes = { + "introitus": ["Eröffnung", "Eröffnung"], + "kyrie": ["Kyrie", "Kyrie"], + "gloria": ["Gloria", "Gloria"], + "kehrvers": ["Antwortpsalm-Kehrvers", "Antwortpsalm-Kehrvers"], + "graduale": ["Graduale", "Graduale"], + "ad_evangelium": ["Ruf vor dem Evangelium/der Passion", "Ruf vor dem Evangelium/der Passion"], + "credo": ["Glaubensbekenntnis", "Glaubensbekenntnis"], + "offertorium": ["Zur Gabenbereitung", "Zur Gabenbereitung"], + "sanctus": ["Sanctus", "Sanctus"], + "agnus": ["Agnus Dei", "Agnus Dei"], + "communio": ["Zur Kommunion", "Zur Kommunion"], + "BMV": ["Marianische Antiphon", "Marianische Antiphon"], + "": ["", "Leer"] + } + + def __init__(self, key=""): + self.key = key + try: + [self.print_name, self.display_name] = self.all_purposes.get(key) + except KeyError: + printf(f"{key} is not a purpose option.") + + @classmethod + def get_all_purposes(cls): + return [cls(key) for key in cls.all_purposes.keys()] \ No newline at end of file diff --git a/templates/purpose_processing.tt b/templates/purpose_processing.tt index 8853b03..318a7bf 100644 --- a/templates/purpose_processing.tt +++ b/templates/purpose_processing.tt @@ -7,9 +7,13 @@ purpose_print = purpose_print ? purpose_print : 'Kyrie'; ELSIF purpose == 'gloria'; purpose_print = purpose_print ? purpose_print : 'Gloria'; - ELSIF purpose == 'graduale'; + ELSIF purpose == 'kehrvers'; purpose_print = 'Antwortpsalm-Kehrvers'; + ELSIF purpose == 'graduale'; + purpose_print = 'Graduale'; ELSIF purpose == 'tractus'; + purpose_print = 'Traktus'; + ELSIF purpose == 'ad_evangelium'; purpose_print = passion ? 'Ruf vor der Passion' : lent ? 'Ruf vor dem Evangelium' : 'Halleluja'; ELSIF purpose == 'credo'; purpose_print = 'Glaubensbekenntnis';