diff --git a/pyproject.toml b/pyproject.toml index 8a71968..5807fac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,13 +23,13 @@ classifiers = [ ] dependencies = [ + "calendula @ file:///Users/cls/Documents/Work/RnD/FLOSS/calendula", "pytest", "pandas", "matplotlib", "altair", "pydantic", "sqlmodel", - "ics", "tatsu<5.8", "babel", "loguru", @@ -40,7 +40,6 @@ dependencies = [ "pymupdf", "flet>=0.81.0,<0.82.0", "pycountry", - "icloudpy", ] [project.urls] @@ -67,6 +66,9 @@ build = [ requires = ["hatchling"] build-backend = "hatchling.build" +[tool.hatch.metadata] +allow-direct-references = true + [tool.hatch.build.targets.wheel] packages = ["tuttle"] diff --git a/tuttle/__init__.py b/tuttle/__init__.py index 28843a7..9b9191d 100644 --- a/tuttle/__init__.py +++ b/tuttle/__init__.py @@ -10,7 +10,6 @@ from . import ( banking, calendar, - cloud, invoicing, model, tax, diff --git a/tuttle/app/preferences/intent.py b/tuttle/app/preferences/intent.py index fdb8a0a..8c96dbe 100644 --- a/tuttle/app/preferences/intent.py +++ b/tuttle/app/preferences/intent.py @@ -50,10 +50,6 @@ def get_preferences(self) -> IntentResult: preferences.theme_mode = preference_item_result.data elif item.value == PreferencesStorageKeys.default_currency_key.value: preferences.default_currency = preference_item_result.data - elif item.value == PreferencesStorageKeys.cloud_acc_id_key.value: - preferences.cloud_acc_id = preference_item_result.data - elif item.value == PreferencesStorageKeys.cloud_provider_key.value: - preferences.cloud_acc_provider = preference_item_result.data elif item.value == PreferencesStorageKeys.language_key.value: preferences.language = preference_item_result.data @@ -67,13 +63,6 @@ def save_preferences(self, preferences: Preferences) -> IntentResult: self.set_preference_key_value_pair( PreferencesStorageKeys.theme_mode_key, preferences.theme_mode ) - self.set_preference_key_value_pair( - PreferencesStorageKeys.cloud_acc_id_key, preferences.cloud_acc_id - ) - self.set_preference_key_value_pair( - PreferencesStorageKeys.cloud_provider_key, - preferences.cloud_acc_provider, - ) self.set_preference_key_value_pair( PreferencesStorageKeys.default_currency_key, preferences.default_currency, diff --git a/tuttle/app/preferences/model.py b/tuttle/app/preferences/model.py index 1522bf1..da15fb7 100644 --- a/tuttle/app/preferences/model.py +++ b/tuttle/app/preferences/model.py @@ -5,8 +5,6 @@ @dataclass class Preferences: theme_mode: str = "" - cloud_acc_id: str = "" - cloud_acc_provider: str = "" default_currency: str = "" language: str = "" @@ -15,8 +13,6 @@ class PreferencesStorageKeys(Enum): """defines the keys used in storing preferences as key-value pairs""" theme_mode_key = "preferred_theme_mode" - cloud_acc_id_key = "preferred_cloud_acc_id" - cloud_provider_key = "preferred_cloud_acc_provider" default_currency_key = "preferred_default_currency" language_key = "preferred_language" diff --git a/tuttle/app/preferences/view.py b/tuttle/app/preferences/view.py index 2c63044..5181985 100644 --- a/tuttle/app/preferences/view.py +++ b/tuttle/app/preferences/view.py @@ -40,8 +40,6 @@ ) from ..res.theme import THEME_MODES -from ...cloud import CloudProvider - class PreferencesScreen(TView, Row): def __init__( @@ -69,22 +67,10 @@ def on_currency_selected(self, e): return self.preferences.default_currency = e.control.value - def on_cloud_account_id_changed(self, e): - if not self.preferences: - return - self.preferences.cloud_acc_id = e.control.value - - def on_cloud_provider_selected(self, e): - if not self.preferences: - return - self.preferences.cloud_acc_provider = e.control.value - def refresh_preferences_items(self): if self.preferences is None: return self.theme_control.update_value(self.preferences.theme_mode) - self.cloud_provider_control.update_value(self.preferences.cloud_acc_provider) - self.cloud_account_id_control.value = self.preferences.cloud_acc_id self.currencies_control.update_value(self.preferences.default_currency) self.languages_control.update_value(self.preferences.language) @@ -182,16 +168,6 @@ def build(self): label="Appearance", hint="", ) - self.cloud_provider_control = views.TDropDown( - label="Cloud Provider", - on_change=self.on_cloud_provider_selected, - items=[item.value for item in CloudProvider], - ) - self.cloud_account_id_control = views.TTextField( - label="Cloud Account Name", - hint="Your cloud account name", - on_change=self.on_cloud_account_id_changed, - ) self.currencies_control = views.TDropDown( label="Default Currency", on_change=self.on_currency_selected, @@ -216,7 +192,7 @@ def build(self): self.tabs = Tabs( selected_index=0, animation_duration=300, - length=3, + length=2, width=self.body_width - SPACE_MD, height=MIN_WINDOW_HEIGHT, content=Column( @@ -225,7 +201,6 @@ def build(self): TabBar( tabs=[ self._make_tab_header("General", Icons.SETTINGS_OUTLINED), - self._make_tab_header("Cloud", Icons.CLOUD_OUTLINED), self._make_tab_header("Locale", Icons.LANGUAGE_OUTLINED), ], ), @@ -239,16 +214,6 @@ def build(self): self.reset_button, ] ), - self._make_tab_content( - [ - views.TBodyText( - txt="Setting up your cloud account will enable you to import time tracking data from your cloud calendar.", - ), - views.Spacer(sm_space=True), - self.cloud_provider_control, - self.cloud_account_id_control, - ] - ), self._make_tab_content( [ self.languages_control, diff --git a/tuttle/app/timetracking/data_source.py b/tuttle/app/timetracking/data_source.py index df3f972..086036c 100644 --- a/tuttle/app/timetracking/data_source.py +++ b/tuttle/app/timetracking/data_source.py @@ -1,19 +1,14 @@ -from typing import Type, Union, Any, Optional - -from pathlib import Path +from typing import Optional from loguru import logger -import icloudpy - -from ..core.abstractions import SQLModelDataSourceMixin -from ..core.intent_result import IntentResult from pandas import DataFrame -from ...calendar import ICSCalendar, ICloudCalendar, CloudCalendar +from ...calendar import ICSCalendar, system_calendar_available from ...dev import singleton -from ...cloud import CloudConnector, CloudProvider from ... import timetracking +SYSTEM_CALENDAR_AVAILABLE = system_calendar_available() + @singleton class TimeTrackingDataFrameSource: @@ -40,14 +35,7 @@ def load_data( self, file_path: str, ) -> DataFrame: - """loads time tracking data from a spreadsheet file - - Arguments: - file_path : path to an uploaded spreadsheet file - - Returns: - DataFrame: time tracking data - """ + """Loads time tracking data from a spreadsheet file.""" logger.info(f"Loading time tracking data from {file_path}...") timetracking_data: DataFrame = timetracking.import_from_spreadsheet( path=file_path, @@ -66,18 +54,7 @@ def load_data( self, ics_file_path, ) -> DataFrame: - """loads time tracking data from a .ics file - - Args: - ics_file_path : path to an uploaded ics or spreadsheet file - - Returns: - IntentResult: - was_intent_successful : bool - data : Calendar if was_intent_successful else None - log_message : str if an error or exception occurs - exception : Exception if an exception occurs - """ + """Loads time tracking data from a .ics file.""" file_calendar: ICSCalendar = ICSCalendar( name=ics_file_path.name, path=ics_file_path, @@ -86,57 +63,21 @@ def load_data( return calendar_data -class TimeTrackingCloudCalendarSource: - """Configures and processes calendar data from the cloud""" - - def __init__(self): - super().__init__() +class TimeTrackingSystemCalendarSource: + """Loads time tracking data from the system calendar.""" - def load_data( - self, - calendar_name: str, - cloud_connector: CloudConnector, - ) -> DataFrame: - """Loads data from a cloud calendar""" - calendar = None - if cloud_connector.provider == CloudProvider.ICloud.value: - icloud_connector: icloudpy.ICloudPyService = ( - cloud_connector.concrete_connector - ) - calendar: CloudCalendar = ICloudCalendar( - name=calendar_name, - icloud_connector=icloud_connector, - ) - else: - raise NotImplementedError - - calendar_data: DataFrame = calendar.to_data() - return calendar_data + def list_calendars(self) -> list[dict]: + """Returns all calendars available on this system.""" + if not SYSTEM_CALENDAR_AVAILABLE: + return [] + from ...calendar import SystemCalendar - def login_to_icloud( - self, - apple_id: str, - password: str, - ) -> CloudConnector: - """Attempts to authenticate user with their icloud account""" - # TODO: error handling - login may fail - logger.info(f"Logging in to iCloud with {apple_id}...") - icloud_connector = icloudpy.ICloudPyService( - apple_id=apple_id, - password=password, - cookie_directory=Path.home() / ".tuttle" / "cookies", - ) - return CloudConnector( - cloud_connector=icloud_connector, - account_name=apple_id, - ) + cal = SystemCalendar() + return cal.list_available_calendars() - """ GOOGLE LOGIN STEPS """ + def load_data(self, calendar_name: str) -> DataFrame: + """Loads events from a named system calendar as time tracking data.""" + from ...calendar import SystemCalendar - def login_to_google( - self, - google_account: str, - google_account_password: str, - ): - """TODO Attempts to authenticate user with their google account""" - raise NotImplementedError + cal = SystemCalendar(name=calendar_name) + return cal.to_data() diff --git a/tuttle/app/timetracking/intent.py b/tuttle/app/timetracking/intent.py index 8115315..38b6a60 100644 --- a/tuttle/app/timetracking/intent.py +++ b/tuttle/app/timetracking/intent.py @@ -1,135 +1,80 @@ -from typing import Optional, Type, Union +from typing import Optional from pathlib import Path from loguru import logger - +from pandas import DataFrame from ..core.abstractions import ClientStorage, Intent from ..core.intent_result import IntentResult -from pandas import DataFrame -from ..preferences.intent import PreferencesIntent -from ..preferences.model import PreferencesStorageKeys from .data_source import ( - TimeTrackingCloudCalendarSource, + SYSTEM_CALENDAR_AVAILABLE, TimeTrackingDataFrameSource, TimeTrackingFileCalendarSource, + TimeTrackingSystemCalendarSource, TimeTrackingSpreadsheetSource, ) -from ...cloud import CloudConnector, CloudProvider -from ...calendar import Calendar class TimeTrackingIntent(Intent): """Handles time tracking intents""" def __init__(self, client_storage: ClientStorage): - - self._cloud_calendar_source = TimeTrackingCloudCalendarSource() self._file_calendar_source = TimeTrackingFileCalendarSource() self._spreadsheet_source = TimeTrackingSpreadsheetSource() self._timetracking_data_frame_source = TimeTrackingDataFrameSource() - self._preferences_intent = PreferencesIntent(client_storage) - - def get_preferred_cloud_account(self) -> IntentResult[Optional[list]]: - """ - Returns: - IntentResult - data as [provider_name, account_name] list if successful else None""" - provider_result = self._preferences_intent.get_preference_by_key( - PreferencesStorageKeys.cloud_provider_key - ) - acc_result = self._preferences_intent.get_preference_by_key( - PreferencesStorageKeys.cloud_acc_id_key - ) - if ( - not provider_result.was_intent_successful - or not acc_result.was_intent_successful - ): - return IntentResult( - was_intent_successful=False, - error_msg="Failed to load account preferences", - ) - return IntentResult( - was_intent_successful=True, data=[provider_result.data, acc_result.data] - ) + if SYSTEM_CALENDAR_AVAILABLE: + self._system_calendar_source = TimeTrackingSystemCalendarSource() + else: + self._system_calendar_source = None def process_timetracking_file(self, file_path: Path) -> IntentResult[DataFrame]: - """processes a time tracking spreadsheet or ics file in the uploads folder - - Returns - ------- - IntentResult - data : time tracking data as a pandas DataFrame if intent successful else None - error_msg : text to display to the user if an error occurs else is empty - """ - # check the file extension. file_path is a Path object + """Processes a time tracking spreadsheet or ics file.""" is_calendar = file_path.suffix == ".ics" if is_calendar: timetracking_data: DataFrame = self._file_calendar_source.load_data( ics_file_path=file_path, ) - return IntentResult( - was_intent_successful=True, - data=timetracking_data, - ) else: timetracking_data: DataFrame = self._spreadsheet_source.load_data( file_path=file_path, ) + return IntentResult( + was_intent_successful=True, + data=timetracking_data, + ) + + def list_system_calendars(self) -> IntentResult[list[dict]]: + """Returns available system calendars.""" + if self._system_calendar_source is None: return IntentResult( - was_intent_successful=True, - data=timetracking_data, + was_intent_successful=False, + error_msg="System calendar is not available on this platform", ) - - def connect_to_cloud( - self, - provider: str, - account_id: str, - password: str, - ) -> IntentResult[CloudConnector]: - """""" try: - # check cloud_calendar_info for the value of the cloud provider - # if it is icloud, call the login_to_icloud method - logger.info( - f"Trying to connect to cloud provider {provider} with account {account_id}" + calendars = self._system_calendar_source.list_calendars() + return IntentResult( + was_intent_successful=True, + data=calendars, ) - if provider == CloudProvider.ICloud.value: - connector: CloudConnector = self._cloud_calendar_source.login_to_icloud( - apple_id=account_id, - password=password, - ) - logger.info( - f"Successfully connected to iCloud with account {account_id}" - ) - return IntentResult( - was_intent_successful=True, - data=connector, - ) - else: - return IntentResult( - was_intent_successful=False, - error_msg=f"Not implemented yet for cloud provider {provider}", - ) except Exception as ex: logger.exception(ex) return IntentResult( was_intent_successful=False, - error_msg=f"Failed to connect to cloud provider {provider}", + error_msg="Failed to list system calendars", exception=ex, ) - def load_from_cloud_calendar( - self, - cloud_connector: CloudConnector, - calendar_name: str, - ) -> IntentResult[DataFrame]: - """Loads time tracking data from a cloud calendar using a cloud connector""" + def load_from_system_calendar(self, calendar_name: str) -> IntentResult[DataFrame]: + """Loads time tracking data from a system calendar.""" + if self._system_calendar_source is None: + return IntentResult( + was_intent_successful=False, + error_msg="System calendar is not available on this platform", + ) try: - calendar_data: DataFrame = self._cloud_calendar_source.load_data( - cloud_connector=cloud_connector, + calendar_data = self._system_calendar_source.load_data( calendar_name=calendar_name, ) return IntentResult( @@ -137,11 +82,10 @@ def load_from_cloud_calendar( data=calendar_data, ) except Exception as ex: - error_message = f"Failed to load data from cloud calendar {calendar_name}" logger.exception(ex) return IntentResult( was_intent_successful=False, - error_msg=error_message, + error_msg=f"Failed to load data from calendar '{calendar_name}'", exception=ex, ) diff --git a/tuttle/app/timetracking/view.py b/tuttle/app/timetracking/view.py index b8de4b7..b9e4b68 100644 --- a/tuttle/app/timetracking/view.py +++ b/tuttle/app/timetracking/view.py @@ -19,75 +19,50 @@ from pandas import DataFrame from ..res import colors, dimens, fonts, res_utils -from ...calendar import Calendar -from ...cloud import CloudConnector - +from .data_source import SYSTEM_CALENDAR_AVAILABLE from .intent import TimeTrackingIntent -class TwoFAPopUp(DialogHandler): - """Prompts user for the two_factor_verification_code during cloud configuration""" +class NewTimeTrackPopUp(DialogHandler): + """Prompts user to load time tracking data from a system calendar, .ics file, or spreadsheet.""" def __init__( self, dialog_controller: Callable[[any, utils.AlertDialogControls], None], - on_submit_callback: Callable[[str], None], - title: Optional[str] = "Enter the verification code", + on_use_file_callback: Callable[[bool, bool], None], + on_use_system_calendar_callback: Callable[[str], None], + system_calendars: list[dict], + system_calendar_error: str = "", ): - dialog_width = 480 - title = title - dialog = AlertDialog( - bgcolor=colors.bg_surface, - content=Container( - content=Column( - scroll=utils.AUTO_SCROLL, - controls=[ - views.THeading(title=title, size=fonts.HEADLINE_4_SIZE), - views.Spacer(xs_space=True), - views.TTextField( - label="Code", - on_change=self.on_code_changed, - ), - views.Spacer(xs_space=True), - ], - ), - width=dialog_width, - ), - actions=[ - views.TPrimaryButton( - label="Verify", - on_click=lambda e: on_submit_callback(self.code), - ), - ], - ) - super().__init__(dialog=dialog, dialog_controller=dialog_controller) - - self.code = "" - - def on_code_changed(self, e): - self.code = e.control.value - + title = "Track your progress" -class NewTimeTrackPopUp(DialogHandler): - """Prompts user to request a timetrack sheet or calendar file or cloud calendar""" + has_calendars = len(system_calendars) > 0 + needs_permission = ( + SYSTEM_CALENDAR_AVAILABLE + and not has_calendars + and bool(system_calendar_error) + ) + show_calendar_picker = SYSTEM_CALENDAR_AVAILABLE and has_calendars + calendar_names = [c["title"] for c in system_calendars] if has_calendars else [] + + self._calendar_dropdown = views.TDropDown( + label="Calendar", + items=calendar_names, + hint="Select a calendar synced to this device", + show=show_calendar_picker, + ) - def __init__( - self, - dialog_controller: Callable[[any, utils.AlertDialogControls], None], - on_use_file_callback: Callable[[bool, bool], None], - on_use_cloud_acc_callback: Callable[[str, str, str, str], None], - preferred_cloud_acc: str, - preferred_acc_provider: str, - ): - display_cloud_option = ( - True if preferred_cloud_acc and preferred_acc_provider else False + permission_hint = views.TBodyText( + txt=( + "Calendar access required. Please grant calendar " + "permission to this app in your system settings, " + "then restart." + ), + color=colors.text_muted, + show=needs_permission, ) - space_between_cloud_controls = views.Spacer(xs_space=True) - space_between_cloud_controls.visible = display_cloud_option - dialog_width = 480 - title = "Track your progress" dialog = AlertDialog( bgcolor=colors.bg_surface, content=Container( @@ -97,38 +72,24 @@ def __init__( views.THeading(title=title, size=fonts.HEADLINE_4_SIZE), views.Spacer(xs_space=True), views.TBodyText( - f"Use calendar from {preferred_acc_provider}", - show=display_cloud_option, - ), - space_between_cloud_controls, - views.TTextField( - label="Calendar Name", - on_change=self.on_calendar_name_changed, - show=display_cloud_option, - ), - space_between_cloud_controls, - views.TTextField( - label="Cloud Password", - hint="Your password will not be stored", - keyboard_type=utils.KEYBOARD_PASSWORD, - on_change=self.on_password_changed, - show=display_cloud_option, + "Load from Calendar", + show=SYSTEM_CALENDAR_AVAILABLE, ), - space_between_cloud_controls, + views.Spacer(xs_space=True), + permission_hint, + self._calendar_dropdown, + views.Spacer(xs_space=True), views.TPrimaryButton( - label="Load from cloud calendar", - icon="cloud", - on_click=lambda e: on_use_cloud_acc_callback( - account_id=preferred_cloud_acc, - provider=preferred_acc_provider, - password=self.password, - calendar_name=self.calendar_name, + label="Load from calendar", + icon="calendar_month", + on_click=lambda _: on_use_system_calendar_callback( + self._calendar_dropdown.value, ), - show=display_cloud_option, + show=show_calendar_picker, ), - space_between_cloud_controls, - views.OrView(show_lines=False, show=display_cloud_option), - space_between_cloud_controls, + views.Spacer(xs_space=True), + views.OrView(show_lines=False, show=SYSTEM_CALENDAR_AVAILABLE), + views.Spacer(xs_space=True), views.TSecondaryButton( label="Upload a calendar (.ics) file", icon="calendar_month", @@ -140,7 +101,7 @@ def __init__( label="Upload a spreadsheet", icon="table_view", on_click=lambda _: on_use_file_callback( - is_spreadsheet=True + is_spreadsheet=True, ), ), views.Spacer(xs_space=True), @@ -150,14 +111,6 @@ def __init__( ), ) super().__init__(dialog=dialog, dialog_controller=dialog_controller) - self.password = "" - self.calendar_name = "" - - def on_calendar_name_changed(self, e): - self.calendar_name = e.control.value - - def on_password_changed(self, e): - self.password = e.control.value class TimeTrackingView(TView, Column): @@ -166,9 +119,9 @@ class TimeTrackingView(TView, Column): def __init__(self, params): super().__init__(params) self.intent = TimeTrackingIntent(client_storage=params.client_storage) - self.preferred_cloud_acc = "" - self.preferred_cloud_provider = "" self.pop_up_handler = None + self._system_calendars: list[dict] = [] + self._system_calendar_error: str = "" object.__setattr__(self, "dataframe_to_display", None) def close_pop_up_if_open(self): @@ -185,14 +138,34 @@ def parent_intent_listener(self, intent: str, data: any): self.pop_up_handler = NewTimeTrackPopUp( dialog_controller=self.dialog_controller, on_use_file_callback=self.on_add_timetrack_from_file, - on_use_cloud_acc_callback=self.on_login_to_cloud, - preferred_cloud_acc=self.preferred_cloud_acc, - preferred_acc_provider=self.preferred_cloud_provider, + on_use_system_calendar_callback=self.on_load_from_system_calendar, + system_calendars=self._system_calendars, + system_calendar_error=self._system_calendar_error, ) self.pop_up_handler.open_dialog() return - """GETTING DATA FROM A FILE""" + # --- System Calendar --- + + def on_load_from_system_calendar(self, calendar_name: Optional[str]): + self.close_pop_up_if_open() + if not calendar_name: + self.show_snack("Please select a calendar", is_error=True) + return + self.set_progress_hint(f"Loading calendar '{calendar_name}'...") + result: IntentResult[DataFrame] = self.intent.load_from_system_calendar( + calendar_name=calendar_name, + ) + self.set_progress_hint(hide_progress=True) + if not result.was_intent_successful: + self.show_snack(result.error_msg, is_error=True) + return + object.__setattr__(self, "dataframe_to_display", result.data) + self.update_timetracking_dataframe() + self.display_dataframe() + self.show_snack("Calendar data loaded.") + + # --- File upload --- def on_add_timetrack_from_file( self, @@ -225,14 +198,10 @@ def on_file_picker_result(self, e): else: self.set_progress_hint(hide_progress=True) - def extract_dataframe_from_file( - self, - ): - + def extract_dataframe_from_file(self): """Execute intent to process file""" if not self.uploaded_file_path: return - # upload complete self.set_progress_hint(f"Upload complete, processing file...") intent_result = self.intent.process_timetracking_file( self.uploaded_file_path, @@ -250,139 +219,7 @@ def extract_dataframe_from_file( self.display_dataframe() self.set_progress_hint(hide_progress=True) - """Cloud calendar setup""" - - def on_login_to_cloud( - self, - account_id: str, - calendar_name: str, - password: str, - provider: str, - ): - """ - This function is used for logging in to a cloud account. - - Parameters: - ---------- - - account_id (str): The ID of the cloud account to log in to. - - calendar_name (str): The name of the calendar to load data from. - - password (str): The password for the account. - - provider (str): The name of the cloud provider (e.g. Google, iCloud). - """ - self.close_pop_up_if_open() - if utils.is_empty_str(account_id): - self.show_snack("No Cloud account was specified") - return - if utils.is_empty_str(calendar_name): - self.show_snack("No calendar name was provided") - return - - if utils.is_empty_str(password): - self.show_snack("Your Cloud password is required") - return - - progress_msg = "Authenticating your account..." - self.set_progress_hint(progress_msg) - result: IntentResult[CloudConnector] = self.intent.connect_to_cloud( - account_id=account_id, - provider=provider, - password=password, - ) - - self.set_progress_hint(hide_progress=True) - if not result.was_intent_successful: - self.show_snack(result.error_msg, is_error=True) - return # exit function - - # get connector object - connector: CloudConnector = result.data - - if connector.requires_2fa: - # request 2FA code - self.request_2fa_code(connector=connector, calendar_name=calendar_name) - return # exit function - self.show_snack(f"Cloud login successful.") - - # load calendar data - self.load_calendar_from_cloud( - calendar_name=calendar_name, - connector=connector, - ) - - def request_2fa_code( - self, - connector: CloudConnector, - calendar_name: str, - ): - """ - This function is used to request a 2FA code from the user. - - Parameters: - ---------- - - connector (CloudConnector): The connector object for the cloud account. - - calendar_name (str): The name of the calendar to load data from. - """ - logger.info(f"Requesting 2FA code for {connector.account_name}") - self.close_pop_up_if_open() - self.pop_up_handler = TwoFAPopUp( - self.dialog_controller, - on_submit_callback=lambda code: self.verify_cloud_connector( - two_fa_code=code, connector=connector, calendar_name=calendar_name - ), - ) - self.pop_up_handler.open_dialog() - - def verify_cloud_connector( - self, - connector: CloudConnector, - two_fa_code: str, - calendar_name: str, - ): - """ - This function is used to verify a 2FA code provided by the user. - It takes in the following parameters: - - connector (CloudConnector): The connector object for the cloud account. - - two_fa_code (str): The 2FA code provided by the user. - - calendar_name (str): The name of the calendar to load data from. - """ - connector.validate_2fa_code(twofa_code=two_fa_code) - if not connector.is_connected: - self.request_2fa_code( - connector=connector, - calendar_name=calendar_name, - ) - self.show_snack( - "The code you provided is incorrect", - is_error=True, - ) - return - self.load_calendar_from_cloud( - calendar_name=calendar_name, - connector=connector, - ) - - def load_calendar_from_cloud( - self, - calendar_name: str, - connector: CloudConnector, - ): - self.set_progress_hint(msg="Loading calendar data") - result: IntentResult[DataFrame] = self.intent.load_from_cloud_calendar( - cloud_connector=connector, - calendar_name=calendar_name, - ) - self.set_progress_hint(hide_progress=True) - if not result.was_intent_successful: - self.show_snack( - result.error_msg, - is_error=True, - ) - return - object.__setattr__(self, "dataframe_to_display", result.data) - self.update_timetracking_dataframe() - self.display_dataframe() - - """ DISPLAYED DATA FRAME """ + # --- DataFrame display --- def load_existing_dataframe(self): result = self.intent.get_timetracking_data() @@ -412,13 +249,14 @@ def display_dataframe(self): def show_no_recorded_timetracks(self): self.no_timetrack_control.visible = True - def load_preferred_cloud_acc(self): - result = self.intent.get_preferred_cloud_account() - if not result.was_intent_successful: - self.show_snack(result.error_msg, is_error=True) - return - self.preferred_cloud_provider = result.data[0] - self.preferred_cloud_acc = result.data[1] + def _load_system_calendars(self): + """Fetch available system calendars (best-effort).""" + result = self.intent.list_system_calendars() + if result.was_intent_successful and result.data: + self._system_calendars = result.data + self._system_calendar_error = "" + else: + self._system_calendar_error = result.error_msg or "" def set_progress_hint(self, msg: str = "", hide_progress=False): if self.mounted: @@ -433,7 +271,7 @@ def did_mount(self): def initialize_data(self): self.loading_indicator.visible = True - self.load_preferred_cloud_acc() + self._load_system_calendars() self.load_existing_dataframe() self.display_dataframe() self.loading_indicator.visible = False diff --git a/tuttle/calendar.py b/tuttle/calendar.py index f01bb5c..a0b4039 100644 --- a/tuttle/calendar.py +++ b/tuttle/calendar.py @@ -1,197 +1,29 @@ -"""Calendar integration.""" -from typing import Optional - -from pathlib import Path -import io -import re -import calendar - -from loguru import logger -import ics -import icloudpy -import getpass -import pandas -import datetime - -from pandera.typing import DataFrame -from pandera import check_io -from pandas import DataFrame - -from . import schema - - -def extract_hashtag(string) -> str: - """Extract the first hashtag from a string.""" - match = re.search(r"(#\S+)", string) - if match: - return match.group(1) - else: - return "" - - -def parse_pyicloud_datetime(dt_list): - """Parse the dates returned by pyicloud.""" - _, year, month, day, hour, minute, _ = dt_list - return datetime.datetime(year, month, day, hour, minute) - - -class Calendar: - """Abstract base class for calendars.""" - - def __init__(self, name: str): - self.name = name - - @check_io(out=schema.time_tracking) - def to_data(self) -> DataFrame: - """Convert events to dataframe.""" - raise NotImplementedError("Abstract base class") - - -class CloudCalendar(Calendar): - """Abstract base class for calendars in the cloud.""" - - pass - - -class ICSCalendar(Calendar): - """An ICS data format based calendar.""" - - def __init__( - self, - name: str, - path: Optional[str] = None, - content: Optional[bytes] = None, - ics_calendar: Optional[ics.Calendar] = None, - ): - super().__init__(name) - if path is not None: - self.path = path - with open(self.path, "r") as cal_file: - self.ical = ics.Calendar(cal_file.read()) - elif content is not None: - self.content = content - with io.TextIOWrapper(io.BytesIO(content), encoding="utf-8") as cal_file: - self.ical = ics.Calendar(cal_file.read()) - elif ics_calendar is not None: - self.ical = ics_calendar - else: - raise ValueError( - "Either a path to or the content of an .ics file must be passed." - ) - - def to_raw_data(self) -> DataFrame: - """Convert .ics calendar events to DataFrame""" - events = [event for event in self.ical.events] - event_data_raw = pandas.DataFrame( - [tuple(event.__dict__.values()) for event in events], - columns=list(events[0].__dict__.keys()), - ) - return event_data_raw - - @check_io(out=schema.time_tracking) - def to_data(self) -> DataFrame: - """Convert ics.Calendar to pandas.DataFrame""" - # TODO: handle errors from data transformation here - event_data = pandas.DataFrame( - [ - ( - event.name, - event.description, - pandas.to_datetime(event.begin.datetime).tz_convert("CET"), - pandas.to_datetime(event.end.datetime).tz_convert("CET"), - event.all_day, - # TODO: handle time zones - # pandas.to_datetime(event.begin.datetime).tz_convert("CET"), - # pandas.to_datetime(event.end.datetime).tz_convert("CET"), - ) - for event in self.ical.events - ], - columns=["title", "description", "begin", "end", "all_day"], - ) - event_data["duration"] = event_data["end"] - event_data["begin"] - # apply the function extract_hashtag to the column title to derive the column tag - event_data["tag"] = event_data["title"].apply(extract_hashtag) - # event_data["time"] = event_data["begin"] - event_data = event_data.set_index("begin") - return event_data - - -class ICloudCalendar(CloudCalendar): - """iCloud calendar.""" - - def __init__( - self, - icloud_connector: icloudpy.ICloudPyService, - name: str, - ): - super().__init__(name) - self.icloud = icloud_connector - calendars = icloud_connector.calendar.calendars() - calendars_df = pandas.DataFrame(calendars) - cal_to_guid = dict( - (cal_name, guid) - for (cal_name, guid) in zip(calendars_df["title"], calendars_df["guid"]) - ) - try: - # calendar id - self.guid = cal_to_guid[self.name] - except KeyError: - raise ValueError(f"iCloud calendar {self.name} not found") - - def to_raw_data(self) -> DataFrame: - """Convert iCloud calendar events to DataFrame""" - all_events = self.icloud.calendar.events( - from_dt=datetime.datetime(1, 1, 1), to_dt=datetime.datetime(2100, 1, 1) - ) - event_data_raw = pandas.DataFrame(all_events) - return event_data_raw - - @check_io(out=schema.time_tracking) - def to_data(self) -> DataFrame: - """Convert iCloud calendar events to time tracking data format.""" - - event_data_raw = self.to_raw_data() - guid = self.guid - event_data = event_data_raw.query("pGuid == @guid") - timetracking_data = pandas.DataFrame().assign( - **{ - "begin": event_data["startDate"].apply(parse_pyicloud_datetime), - "end": event_data["endDate"].apply(parse_pyicloud_datetime), - "title": event_data["title"], - # TODO: extract tag - "tag": extract_hashtag(event_data["title"]), - "description": event_data["description"], - "all_day": event_data["allDay"], - } - ) - # TODO: handle timezones - timetracking_data = timetracking_data.assign( - **{ - "duration": event_data["duration"].apply( - lambda m: datetime.timedelta(minutes=m) - ) - } - ) - timetracking_data = timetracking_data.set_index("begin") - return timetracking_data - - -class GoogleCalendar(CloudCalendar): - """Google calendar""" - - def to_data(self) -> DataFrame: - raise NotImplementedError("TODO") - - -def get_month_start_end(month_str): - # Parse the string into a datetime object - dt = datetime.datetime.strptime(month_str, "%B %Y") - - # Get the date information from the datetime object - year, month = dt.date().year, dt.date().month - - # Get the start and end dates of the month - start_date = datetime.date(year, month, 1) - end_date = datetime.date(year, month, calendar.monthrange(year, month)[1]) - - return start_date, end_date +"""Calendar integration — delegated to calendula.""" + +from calendula.calendar import ( + Calendar, + CloudCalendar, + GoogleCalendar, + ICloudCalendar, + ICSCalendar, + MacOSCalendar, + SystemCalendar, + extract_hashtag, + get_month_start_end, + parse_pyicloud_datetime, + system_calendar_available, +) + +__all__ = [ + "Calendar", + "CloudCalendar", + "GoogleCalendar", + "ICloudCalendar", + "ICSCalendar", + "MacOSCalendar", + "SystemCalendar", + "extract_hashtag", + "get_month_start_end", + "parse_pyicloud_datetime", + "system_calendar_available", +] diff --git a/tuttle/cloud.py b/tuttle/cloud.py deleted file mode 100644 index 4d19d9a..0000000 --- a/tuttle/cloud.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Cloud connectors.""" - -from typing import Optional, Union, Any - -from enum import Enum - -from loguru import logger -import getpass -import icloudpy -from icloudpy import ICloudPyService -from .model import ICloudAccount, GoogleAccount -from dataclasses import dataclass - - -class CloudProvider(Enum): - # TODO: add more cloud providers - # Google = "Google" - ICloud = "iCloud" - - def __str__(self) -> str: - return str(self.value) - - -class CloudConnector: - """Wraps and abstracts the cloud API connection objects of various cloud providers""" - - def __init__( - self, - cloud_connector: Union[icloudpy.ICloudPyService, Any], - account_name: Optional[str] = None, - ): - self.concrete_connector = cloud_connector - self.account_name = account_name - - @property - def provider(self) -> str: - """Returns the cloud provider name""" - if isinstance(self.concrete_connector, icloudpy.ICloudPyService): - return "iCloud" - else: - return "Unknown" - - @property - def requires_2fa(self) -> bool: - """Returns True if the cloud connector requires 2fa""" - if isinstance(self.concrete_connector, icloudpy.ICloudPyService): - return self.concrete_connector.is_trusted_session == False - else: - raise NotImplementedError - - @property - def is_connected(self) -> bool: - if isinstance(self.concrete_connector, icloudpy.ICloudPyService): - icloud_connector: icloudpy.ICloudPyService = self.concrete_connector - return icloud_connector.is_trusted_session - else: - raise NotImplementedError - - def validate_2fa_code( - self, - twofa_code: str, - ): - """Validates a 2fa code for the cloud connector""" - if isinstance(self.concrete_connector, icloudpy.ICloudPyService): - icloud_connector: icloudpy.ICloudPyService = self.concrete_connector - icloud_connector.validate_2fa_code(twofa_code) - else: - raise NotImplementedError diff --git a/tuttle/model.py b/tuttle/model.py index 5143e65..82eb0f3 100644 --- a/tuttle/model.py +++ b/tuttle/model.py @@ -135,17 +135,6 @@ class User(SQLModel, table=True): default=None, description="Value Added Tax number of the user, legally required for invoices.", ) - # User 1:1* ICloudAccount - icloud_account_id: Optional[int] = Field( - default=None, foreign_key="icloudaccount.id" - ) - icloud_account: Optional["ICloudAccount"] = Relationship(back_populates="user") - # User 1:1* Google Account - # TODO: Google account - # google_account_id: Optional[int] = Field( - # default=None, foreign_key="googleaccount.id" - # ) - # google_account: Optional["GoogleAccount"] = Relationship(back_populates="user") # User 1:1 business BankAccount bank_account_id: Optional[int] = Field(default=None, foreign_key="bankaccount.id") bank_account: Optional["BankAccount"] = Relationship( @@ -169,18 +158,6 @@ def bank_account_not_set(self) -> bool: return False -class ICloudAccount(SQLModel, table=True): - id: Optional[int] = Field(default=None, primary_key=True) - user_name: str - user: User = OneToOneRelationship(back_populates="icloud_account") - - -class GoogleAccount(SQLModel, table=True): - id: Optional[int] = Field(default=None, primary_key=True) - user_name: str - # user: User = OneToOneRelationship(back_populates="google_account") - - class Bank(SQLModel, table=True): """A bank.""" diff --git a/tuttle/schema.py b/tuttle/schema.py index fb25cc3..237d4cf 100644 --- a/tuttle/schema.py +++ b/tuttle/schema.py @@ -1,35 +1,15 @@ -"""Pandera schemata.""" -import pandera.pandas as pa +"""Pandera schemata — time tracking schemas delegated to calendula.""" + +from calendula.schema import time_planning, time_tracking + from pandera.pandas import ( - # SchemaModel, - DataFrameSchema, Column, - Index, + DataFrameSchema, DateTime, - Timedelta, - String, Decimal, - Bool, -) - - -time_tracking = DataFrameSchema( - # TODO: fix datetime type - # index=Index(DateTime, name="begin", allow_duplicates=True), - columns={ - # "begin": Column(Timestamp, nullable=True), - # "end": Column(DateTime, nullable=True), - "title": Column(String, nullable=True), - "tag": Column(String), - "description": Column(String, nullable=True), - "duration": Column(Timedelta), - "all_day": Column(Bool, nullable=True), - }, + String, ) -time_planning = time_tracking # REVIEW: identical? - - ledger = DataFrameSchema( columns={ "date": Column(DateTime), @@ -40,3 +20,9 @@ "amount": Column(Decimal), }, ) + +__all__ = [ + "time_tracking", + "time_planning", + "ledger", +] diff --git a/tuttle/timetracking.py b/tuttle/timetracking.py index c1b5351..3f870d3 100644 --- a/tuttle/timetracking.py +++ b/tuttle/timetracking.py @@ -1,18 +1,25 @@ -from typing import Tuple, Union, Optional, List, Type +"""Time tracking — import functions delegated to calendula.""" import datetime -from dataclasses import dataclass -import pandas from pandas import DataFrame from pandera import check_io from pandera.typing import DataFrame -from tuttle.dev import deprecated - from . import schema -from .calendar import Calendar, ICloudCalendar, ICSCalendar -from .model import Project, Timesheet, TimeTrackingItem, User +from .model import Project, Timesheet, TimeTrackingItem + +# --- Delegated to calendula --- + +from calendula.timetracking import ( + import_from_calendar, + import_from_spreadsheet, + get_time_planning_data, + TimetrackingSpreadsheetPreset, + TogglPreset, +) + +# --- Tuttle-specific (coupled to tuttle's model layer) --- def generate_timesheet( @@ -47,7 +54,6 @@ def generate_timesheet( project.contract.unit.to_timedelta() * project.contract.units_per_workday ) if item_description: - # TODO: extract item description from calendar ts_table["description"] = item_description period_str = f"{start_key} - {end_key}" @@ -76,125 +82,6 @@ def export_timesheet( table.to_excel(path, index=False) -# IMPORT - - -@check_io(out=schema.time_tracking) -def import_from_calendar(cal: Calendar) -> DataFrame: - """Convert the raw calendar to time tracking data table.""" - if issubclass(type(cal), ICloudCalendar): - timetracking_data = cal.to_data() - return timetracking_data - elif issubclass(type(cal), ICSCalendar): - timetracking_data = cal.to_data() - return timetracking_data - else: - raise NotImplementedError() - - -class TimetrackingSpreadsheetPreset: - tag_col: str - begin_col: Union[str, List[str]] - end_col: Union[str, List[str]] - duration_col: str - title_col: str - description_col: str - - -@dataclass -class TogglPreset(TimetrackingSpreadsheetPreset): - tag_col = "Project" - begin_col = ["Start date", "Start time"] - end_col = ["End date", "End time"] - duration_col = "Duration" - title_col = "Task" - description_col = "Description" - all_day_col = None - - -def infer_spreadsheet_preset(data: DataFrame) -> Type[TimetrackingSpreadsheetPreset]: - """Infer the spreadsheet preset from the columns of the dataframe.""" - raise NotImplementedError("TODO") - - -@check_io( - out=schema.time_tracking, -) -def import_from_spreadsheet( - path, - preset: Optional[Type[TimetrackingSpreadsheetPreset]] = None, - tag_col: Optional[str] = None, - begin_col: Optional[Union[str, List[str]]] = None, - end_col: Optional[Union[str, List[str]]] = None, - duration_col: Optional[str] = None, - title_col: Optional[str] = None, - description_col: Optional[str] = None, - all_day_col: Optional[str] = None, -) -> DataFrame: - """Import time tracking data from a .csv file.""" - if preset: - tag_col = preset.tag_col - begin_col = preset.begin_col - end_col = preset.end_col - duration_col = preset.duration_col - title_col = preset.title_col - description_col = preset.description_col - - assert tag_col is not None - assert begin_col is not None - assert end_col is not None - assert duration_col is not None - - raw_data = pandas.read_csv( - path, - engine="python", - dtype={ - title_col: str, - }, - ) - - # combine date and time if separate - if isinstance(begin_col, list): - begin_date_col, begin_time_col = begin_col - raw_data["begin"] = raw_data[begin_date_col] + " " + raw_data[begin_time_col] - begin_col = "begin" - if isinstance(end_col, list): - end_date_col, end_time_col = end_col - raw_data["end"] = raw_data[end_date_col] + " " + raw_data[end_time_col] - end_col = "end" - - raw_data[begin_col] = pandas.to_datetime(raw_data[begin_col]) - raw_data[end_col] = pandas.to_datetime(raw_data[end_col]) - - timetracking_data = raw_data.rename( - columns={ - title_col: "title", - tag_col: "tag", - duration_col: "duration", - description_col: "description", - begin_col: "begin", - end_col: "end", - } - ) - timetracking_data["duration"] = pandas.to_timedelta(timetracking_data["duration"]) - - if title_col is None: - timetracking_data["title"] = "" - else: - timetracking_data["title"] = timetracking_data["title"].fillna("") - if begin_col is None: - timetracking_data["begin"] = pandas.NaT - if end_col is None: - timetracking_data["end"] = pandas.NaT - if description_col is None: - timetracking_data["description"] = "" - if all_day_col is None: - timetracking_data["all_day"] = False - - timetracking_data = timetracking_data.set_index("begin") - return timetracking_data - - # ANALYSIS @@ -222,26 +109,5 @@ def progress( .groupby("tag") .sum() ) - # TODO: work with project.unit budget = project.contract.volume * datetime.timedelta(hours=1) return total_time.loc[tag]["duration"] / budget - - -@check_io( - out=schema.time_planning, -) -def get_time_planning_data( - source, - from_date: datetime.date = None, -) -> DataFrame: - """Get time planning data from a source.""" - if from_date is None: - from_date = datetime.date.today() - if issubclass(type(source), Calendar): - cal = source - planning_data = cal.to_data() - elif isinstance(source, pandas.DataFrame): - planning_data = source - schema.time_tracking.validate(planning_data) - planning_data = planning_data[str(from_date) :] - return planning_data diff --git a/tuttle_tests/test_app_start.py b/tuttle_tests/test_app_start.py index 27b3d14..85ce7d5 100644 --- a/tuttle_tests/test_app_start.py +++ b/tuttle_tests/test_app_start.py @@ -32,7 +32,6 @@ "tuttle.rendering", "tuttle.tax", "tuttle.banking", - "tuttle.cloud", "tuttle.time", "tuttle.dataviz", "tuttle.os_functions", diff --git a/uv.lock b/uv.lock index 265aad9..1f5030d 100644 --- a/uv.lock +++ b/uv.lock @@ -350,6 +350,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1d/e3/fa60c47d7c344533142eb3af0b73234ef8ea3fb2da742ab976b947e717df/bump2version-1.0.1-py2.py3-none-any.whl", hash = "sha256:37f927ea17cde7ae2d7baf832f8e80ce3777624554a653006c9144f8017fe410", size = 22030, upload-time = "2020-10-07T18:38:38.148Z" }, ] +[[package]] +name = "calendula" +version = "0.0.1" +source = { directory = "../../../../../RnD/FLOSS/calendula" } +dependencies = [ + { name = "icloudpy" }, + { name = "ics" }, + { name = "loguru" }, + { name = "openpyxl" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pandera" }, + { name = "pydantic" }, + { name = "pyobjc-framework-eventkit", marker = "sys_platform == 'darwin'" }, + { name = "pytest" }, + { name = "python-dateutil" }, + { name = "rich" }, + { name = "sqlmodel" }, + { name = "typer" }, +] + +[package.metadata] +requires-dist = [ + { name = "coverage", marker = "extra == 'test'" }, + { name = "icloudpy" }, + { name = "ics" }, + { name = "ipdb", marker = "extra == 'test'" }, + { name = "loguru" }, + { name = "openpyxl" }, + { name = "pandas" }, + { name = "pandera" }, + { name = "pydantic" }, + { name = "pyobjc-framework-eventkit", marker = "sys_platform == 'darwin'" }, + { name = "pytest", specifier = ">=8.4.1" }, + { name = "pytest", marker = "extra == 'test'" }, + { name = "python-dateutil" }, + { name = "rich" }, + { name = "ruff", marker = "extra == 'test'" }, + { name = "sqlmodel" }, + { name = "ty", marker = "extra == 'test'" }, + { name = "typer" }, +] +provides-extras = ["test"] + [[package]] name = "certifi" version = "2024.12.14" @@ -862,6 +906,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -2355,6 +2408,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, ] +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + [[package]] name = "overrides" version = "7.7.0" @@ -3011,6 +3076,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/99/fe4a7752990bf65277718fffbead4478de9afd1c7288d7a6d643f79a6fa7/pymupdf-1.27.1-cp310-abi3-win_amd64.whl", hash = "sha256:4b6268dff3a9d713034eba5c2ffce0da37c62443578941ac5df433adcde57b2f", size = 19236703, upload-time = "2026-02-11T15:04:19.607Z" }, ] +[[package]] +name = "pyobjc-core" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532, upload-time = "2025-11-14T10:08:28.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/bf/3dbb1783388da54e650f8a6b88bde03c101d9ba93dfe8ab1b1873f1cd999/pyobjc_core-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:93418e79c1655f66b4352168f8c85c942707cb1d3ea13a1da3e6f6a143bacda7", size = 676748, upload-time = "2025-11-14T09:30:50.023Z" }, + { url = "https://files.pythonhosted.org/packages/95/df/d2b290708e9da86d6e7a9a2a2022b91915cf2e712a5a82e306cb6ee99792/pyobjc_core-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c918ebca280925e7fcb14c5c43ce12dcb9574a33cccb889be7c8c17f3bcce8b6", size = 671263, upload-time = "2025-11-14T09:31:35.231Z" }, + { url = "https://files.pythonhosted.org/packages/64/5a/6b15e499de73050f4a2c88fff664ae154307d25dc04da8fb38998a428358/pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962", size = 678335, upload-time = "2025-11-14T09:32:20.107Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a", size = 677370, upload-time = "2025-11-14T09:33:05.273Z" }, + { url = "https://files.pythonhosted.org/packages/1b/f0/4b4ed8924cd04e425f2a07269943018d43949afad1c348c3ed4d9d032787/pyobjc_core-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:177aaca84bb369a483e4961186704f64b2697708046745f8167e818d968c88fc", size = 719586, upload-time = "2025-11-14T09:33:53.302Z" }, + { url = "https://files.pythonhosted.org/packages/25/98/9f4ed07162de69603144ff480be35cd021808faa7f730d082b92f7ebf2b5/pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43", size = 670164, upload-time = "2025-11-14T09:34:37.458Z" }, + { url = "https://files.pythonhosted.org/packages/62/50/dc076965c96c7f0de25c0a32b7f8aa98133ed244deaeeacfc758783f1f30/pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882", size = 712204, upload-time = "2025-11-14T09:35:24.148Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/aa/2b2d7ec3ac4b112a605e9bd5c5e5e4fd31d60a8a4b610ab19cc4838aa92a/pyobjc_framework_cocoa-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9b880d3bdcd102809d704b6d8e14e31611443aa892d9f60e8491e457182fdd48", size = 383825, upload-time = "2025-11-14T09:40:28.354Z" }, + { url = "https://files.pythonhosted.org/packages/3f/07/5760735c0fffc65107e648eaf7e0991f46da442ac4493501be5380e6d9d4/pyobjc_framework_cocoa-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f52228bcf38da64b77328787967d464e28b981492b33a7675585141e1b0a01e6", size = 383812, upload-time = "2025-11-14T09:40:53.169Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/ee4f27ec3920d5c6fc63c63e797c5b2cc4e20fe439217085d01ea5b63856/pyobjc_framework_cocoa-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:547c182837214b7ec4796dac5aee3aa25abc665757b75d7f44f83c994bcb0858", size = 384590, upload-time = "2025-11-14T09:41:17.336Z" }, + { url = "https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118", size = 384689, upload-time = "2025-11-14T09:41:41.478Z" }, + { url = "https://files.pythonhosted.org/packages/23/3b/b9f61be7b9f9b4e0a6db18b3c35c4c4d589f2d04e963e2174d38c6555a92/pyobjc_framework_cocoa-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:914b74328c22d8ca261d78c23ef2befc29776e0b85555973927b338c5734ca44", size = 388843, upload-time = "2025-11-14T09:42:05.719Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/f777cc9e775fc7dae77b569254570fe46eb842516b3e4fe383ab49eab598/pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a", size = 384932, upload-time = "2025-11-14T09:42:29.771Z" }, + { url = "https://files.pythonhosted.org/packages/58/27/b457b7b37089cad692c8aada90119162dfb4c4a16f513b79a8b2b022b33b/pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc", size = 388970, upload-time = "2025-11-14T09:42:53.964Z" }, +] + +[[package]] +name = "pyobjc-framework-eventkit" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "pyobjc-framework-cocoa", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/42/4ec97e641fdcf30896fe76476181622954cb017117b1429f634d24816711/pyobjc_framework_eventkit-12.1.tar.gz", hash = "sha256:7c1882be2f444b1d0f71e9a0cd1e9c04ad98e0261292ab741fc9de0b8bbbbae9", size = 28538, upload-time = "2025-11-14T10:15:07.878Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/35/142f43227627d6324993869d354b9e57eb1e88c4e229e2271592254daf25/pyobjc_framework_eventkit-12.1-py2.py3-none-any.whl", hash = "sha256:3d2d36d5bd9e0a13887a6ac7cdd36675985ebe2a9cb3cdf8cec0725670c92c60", size = 6820, upload-time = "2025-11-14T09:48:14.035Z" }, +] + [[package]] name = "pyparsing" version = "3.3.2" @@ -3813,10 +3924,9 @@ source = { editable = "." } dependencies = [ { name = "altair" }, { name = "babel" }, + { name = "calendula" }, { name = "faker" }, { name = "flet" }, - { name = "icloudpy" }, - { name = "ics" }, { name = "loguru" }, { name = "matplotlib" }, { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -3852,10 +3962,9 @@ dev = [ requires-dist = [ { name = "altair" }, { name = "babel" }, + { name = "calendula", directory = "../../../../../RnD/FLOSS/calendula" }, { name = "faker" }, { name = "flet", specifier = ">=0.81.0,<0.82.0" }, - { name = "icloudpy" }, - { name = "ics" }, { name = "loguru" }, { name = "matplotlib" }, { name = "pandas" },