def sync_or_retrieve_time_entries( context_obj: dict, workspace: Workspace, start: datetime, stop: datetime, *, project: Optional[Project] = None) -> List[TimeEntry]: caching = retrieve_cache_from_context(context_obj) downloader = retrieve_downloader_from_context(context_obj) # Download any new time entries if remote sync is enabled. if context_obj['sync'] is True: current_time_entries = downloader.download_time_entries(start, stop) if current_time_entries: caching.update_time_entry_cache(current_time_entries) else: # Otherwise we just pull from the local cache. current_time_entries = caching.retrieve_time_entry_cache() # Neither downloading from remote nor pulling from local cache does any filtering, so let's do that. current_time_entries = TimeEntryFilter.filter_on_workspace( current_time_entries, workspace) # It also doesn't filter on start/stop, so let's do that too. current_time_entries = TimeEntryFilter.filter_on_date_range( current_time_entries, start, stop) # Time entries _can_ be filtered on project if it is provided. current_time_entries = TimeEntryFilter.filter_on_project( current_time_entries, project) return current_time_entries
def timer_resume(context: click.Context): time_entries = context.obj['data']['time_entries'] time_entries = sorted(time_entries, key=lambda entry: entry.last_updated, reverse=True) commands = retrieve_commands_from_context(context.obj) caching = retrieve_cache_from_context(context.obj) try: time_entry_builder = TimeEntryBuilder(time_entries[0]) time_entry_builder.start_time(dt=datetime.now(get_localzone())) time_entry_builder.unset_stop_time() time_entry_builder.unset_duration() started_entry = commands.start_time_entry(time_entry_builder.build()) if started_entry: caching.update_time_entry_cache([started_entry]) click.echo( click.style('SUCCESS', fg='green') + f': Resumed time entry({started_entry.description}).') except HTTPError as e: logging.getLogger(__name__).error(e) click.echo( click.style('ERROR', fg='red') + f': failed to resume the latest timer. An exception' f' has been logged; check the logs for more information.')
def tag_delete(context: click.Context, name: str, multiple: bool): workspace = retrieve_workspace_from_context(context.obj) if not workspace: # Workspace filter was not strict enough or there is no default # workspace configured. click.echo(click.style('ERROR', fg='red') + ': a single workspace must be specified (default or otherwise' ' ) when deleting a tag.') return current_tags = context.obj['data']['tags'] if not current_tags: click.echo(click.style('WARNING') + f': there are no tags in this workspace({workspace.name}).') return if name: current_tags = TagFilter.filter_on_name( current_tags, name ) if not current_tags: click.echo(click.style('WARNING', fg='yellow') + f': no tags exist with this name({name}' f' in this workspace({workspace.name})') return if not multiple and len(current_tags) > 1: click.echo(click.style('ERROR', fg='red') + ': multiple tags found matching the criteria, but --multiple ' 'not specified.. please use the --multiple option or tighten ' 'the search criteria.') return cache = retrieve_cache_from_context(context.obj) commands = retrieve_commands_from_context(context.obj) # This raises if there is any issues, which means it doesn't get added # to the local cache. try: for tag in current_tags: commands.delete_tag(tag) # The new tag was removed from the remote Toggl servers, so now # we can remove it from our local cache as well. cache.remove_tag_from_cache(tag) click.echo(click.style('SUCCESS', fg='green') + f': deleted tag({tag.name})!') except Exception as e: logging.getLogger(__name__).error(e) click.echo(click.style('ERROR', fg='red') + f': failed to delete the tag(s). An exception has been logged;' ' check the logs for more information.')
def timer_start(context: click.Context, description: str, tags: str): time_entry_builder = TimeEntryBuilder() workspace = retrieve_workspace_from_context(context.obj) if not workspace: # Workspace filter was not strict enough or there is no default # workspace configured. click.echo( click.style('ERROR', fg='red') + ': a single workspace must be specified (default or otherwise' ' ) when starting a timer.') return time_entry_builder.workspace_identifier(workspace.identifier) current_tags = context.obj['data']['tags'] if tags: current_tags = TagFilter.filter_on_names(current_tags, tags.split(',')) if not current_tags: click.echo( click.style('ERROR', fg='red') + f': no tags match the specified names.') return time_entry_builder.tags([tag.name for tag in current_tags]) project = retrieve_project_from_context(context.obj) if project: time_entry_builder.project_identifier(project.identifier) if description: time_entry_builder.description(description) start_time = datetime.now(tz=get_localzone()) time_entry_builder.start_time(dt=start_time) caching = retrieve_cache_from_context(context.obj) commands = retrieve_commands_from_context(context.obj) try: entry = time_entry_builder.build() entry = commands.start_time_entry(entry) click.echo( click.style('SUCCESS', fg='green') + f': started new time entry({entry.description}) in' f' workspace({workspace.name}); start = {start_time.isoformat()}.') caching.update_time_entry_cache([entry]) except HTTPError as e: logging.getLogger(__name__).error(e) click.echo( click.style('ERROR', fg='red') + f': failed to start the timer. An exception has been logged;' ' check the logs for more information.')
def project_add(context: click.Context, name: str, color: str): workspace = retrieve_workspace_from_context(context.obj) if not workspace: # Workspace filter was not strict enough or there is no default # workspace configured. click.echo( click.style('ERROR', fg='red') + ': a single workspace must be specified (default or otherwise' ' ) when adding a new project.') return # If there is a project with the same name existing already, do nothing. current_projects = context.obj['data']['projects'] if current_projects: current_projects = ProjectFilter.filter_on_name(current_projects, name) if current_projects: click.echo( click.style('WARNING', fg='yellow') + f': project({name}) with this name already' f' exists in this workspace({workspace.name}).') return project = ProjectBuilder() \ .name(name) \ .workspace_identifier(workspace.identifier) \ .color(Project.Color.from_string(color)) \ .build() commands = retrieve_commands_from_context(context.obj) cache = retrieve_cache_from_context(context.obj) # This raises if there is any issues, which means it doesn't get added # to the local cache. try: added_project = commands.add_project(project) # The new project was added to the remote Toggl servers, so now # we can add it to our local cache as well. cache.update_project_cache([added_project]) click.echo( click.style('SUCCESS', fg='green') + f': added new project({added_project.name}) to' f' workspace({workspace.name})!') except Exception as e: logging.getLogger(__name__).error(e) click.echo( click.style('ERROR', fg='red') + f': failed to add new project({name}) to workspace({workspace.name}).' ' An exception has been logged; check the logs for more information.' )
def sync_or_retrieve_workspaces(context_obj: dict) -> List[Workspace]: caching = retrieve_cache_from_context(context_obj) downloader = retrieve_downloader_from_context(context_obj) # Download any new workspaces added remotely (this is only possible via # the web interface anyway), if remote sync is enabled. if context_obj['sync'] is True: current_workspaces = downloader.download_workspaces() if current_workspaces: caching.update_workspace_cache(current_workspaces) else: # Otherwise we just pull from the local cache. current_workspaces = caching.retrieve_workspace_cache() return current_workspaces
def sync_or_retrieve_tags(context_obj: dict, workspace: Workspace) -> List[Tag]: caching = retrieve_cache_from_context(context_obj) downloader = retrieve_downloader_from_context(context_obj) # Download any new tags if remote sync is enabled. if context_obj['sync'] is True: current_tags = downloader.download_tags(workspace) if current_tags: caching.update_tag_cache(current_tags) else: # Otherwise we just pull from the local cache. current_tags = caching.retrieve_tag_cache() if current_tags: # Retrieval from the DB doesn't filter on workspace, so we do that ourselves. current_tags = TagFilter.filter_on_workspace(current_tags, workspace) return current_tags
def tag_add(context: click.Context, name: str): workspace = retrieve_workspace_from_context(context.obj) if not workspace: # Workspace required to add a new tag. click.echo(click.style('ERROR', fg='red') + ': a workspace is required when adding a new tag.') return current_tags = context.obj['data']['tags'] if current_tags: current_tags = TagFilter.filter_on_name( current_tags, name) if current_tags: click.echo(click.style('WARNING', fg='yellow') + f': tag({name}) with this name already' f' exists in this workspace({workspace.name}).') return tag = TagBuilder()\ .name(name)\ .workspace_identifier(workspace.identifier)\ .build() cache = retrieve_cache_from_context(context.obj) commands = retrieve_commands_from_context(context.obj) # This raises if there is any issues, which means it doesn't get added # to the local cache. try: added_tag = commands.add_tag(tag) # The new tag was added to the remote Toggl servers, so now # we can add it to our local cache as well. cache.update_tag_cache([added_tag]) click.echo(click.style('SUCCESS', fg='green') + f': added new tag({added_tag.name}) to' f' workspace({workspace.name})!') except Exception as e: logging.getLogger(__name__).error(e) click.echo(click.style('ERROR', fg='red') + f': failed to add new tag({name}) to workspace({workspace.name}).' ' An exception has been logged; check the logs for more information.')
def timer_stop(context: click.Context): downloader = retrieve_downloader_from_context(context.obj) entry = downloader.get_current_time_entry() if not entry: click.echo('No entry is currently running.') return commands = retrieve_commands_from_context(context.obj) caching = retrieve_cache_from_context(context.obj) try: entry = commands.stop_time_entry(entry) caching.update_time_entry_cache([entry]) click.echo( click.style('SUCCESS', fg='green') + f': stopped the current running timer({entry.description}).') except HTTPError as e: logging.getLogger(__name__).error(e) click.echo( click.style('ERROR', fg='red') + f': failed to stop the current running timer. An exception' f' has been logged; check the logs for more information.')
def project_update(context: click.Context, old_name: str, old_color: str, new_name: str, new_color: str, multiple: bool): workspace = retrieve_workspace_from_context(context.obj) if not workspace: # Workspace filter was not strict enough or there is no default # workspace configured. click.echo( click.style('ERROR', fg='red') + ': a single workspace must be specified (default or otherwise' ' ) when updating a project.') return current_projects = context.obj['data']['projects'] if not current_projects: click.echo( click.style('WARNING', fg='yellow') + f': no projects to update in this workspace({workspace.name}).') return if old_color: # See if the user specified a color to search on. current_projects = ProjectFilter.filter_on_color( current_projects, Project.Color.from_string(old_color)) if not current_projects: click.echo( click.style('WARNING', fg='yellow') + f': no projects exist with this color({old_color})' f' in this workspace({workspace.name}).') return if old_name: current_projects = ProjectFilter.filter_on_name( current_projects, old_name) if not current_projects: click.echo( click.style('WARNING', fg='yellow') + f': no projects exist with this name({old_name}' f' in this workspace({workspace.name}).') return if not multiple and len(current_projects) > 1: click.echo( click.style('ERROR', fg='red') + ': multiple projects found matching the criteria, but --multiple ' 'not specified.. please used the --multiple option or tighten ' 'the search criteria.') return current_builds: List[ProjectBuilder] = [] for project in current_projects: current_builds.append(ProjectBuilder(project)) if new_name: for build in current_builds: assert isinstance(build, ProjectBuilder) build.name(new_name) if new_color: for build in current_builds: assert isinstance(build, ProjectBuilder) build.color(Project.Color.from_string(new_color)) cache = retrieve_cache_from_context(context.obj) commands = retrieve_commands_from_context(context.obj) try: built_projects = [] for project_builder in current_builds: project = project_builder.build() project = commands.update_project(project) click.echo( click.style('SUCCESS', fg='green') + f': updated project({project.name}) in' f' workspace({workspace.name}).') built_projects.append(project) cache.update_project_cache(built_projects) except Exception as e: logging.getLogger(__name__).error(e) click.echo( click.style('ERROR', fg='red') + f': failed to update the project(s). An exception has been logged;' ' check the logs for more information.')
def project_delete(context: click.Context, name: str, color: str, multiple: bool): workspace = retrieve_workspace_from_context(context.obj) if not workspace: # Workspace filter was not strict enough or there is no default # workspace configured. click.echo( click.style('ERROR', fg='red') + ': a single workspace must be specified (default or otherwise' ' ) when deleting a project.') return current_projects = context.obj['data']['projects'] if not current_projects: click.echo( click.style('WARNING', fg='yellow') + f': no projects exist in this workspace({workspace.name})!') return if color: # See if the user specified a color to search on. current_projects = ProjectFilter.filter_on_color( current_projects, Project.Color.from_string(color)) if not current_projects: click.echo( click.style('WARNING', fg='yellow') + f': no projects exist with this color({color})' f' in this workspace({workspace.name}).') return if name: current_projects = ProjectFilter.filter_on_name(current_projects, name) if not current_projects: click.echo( click.style('WARNING', fg='yellow') + f': no projects exist with this name({name}' f' in this workspace({workspace.name}).') return if not multiple and len(current_projects) > 1: # If multiple matches are found but the user didn't specify the --multiple option, # prevent accidental deletion by requiring the user do it again but with the option # enabled. click.echo( click.style('ERROR', fg='red') + ': multiple projects found matching the criteria, but --multiple ' 'not specified.. please use the --multiple option or tighten ' 'the search criteria.') return commands = retrieve_commands_from_context(context.obj) cache = retrieve_cache_from_context(context.obj) try: if len(current_projects) > 1: commands.delete_projects(current_projects) for project in current_projects: cache.remove_project_from_cache(project) click.echo( click.style('SUCCESS', fg='green') + f': deleted project({project.name})!') else: commands.delete_project(current_projects[0]) cache.remove_project_from_cache(current_projects[0]) click.echo( click.style('SUCCESS', fg='green') + f': deleted project({current_projects[0].name})!') except Exception as e: logging.getLogger(__name__).error(e) click.echo( click.style('ERROR', fg='red') + f': failed to delete the project(s). An exception has been logged;' ' check the logs for more information.')
def timer_update(context: click.Context, old_description: str, new_description: str, new_project: str, old_tags: str, add_tags: str, remove_tags: str, new_start_time: datetime, new_duration: int, new_stop_time: datetime, multiple: bool): workspace = retrieve_workspace_from_context(context.obj) if not workspace: # Workspace filter was not strict enough or there is no default # workspace configured. click.echo( click.style('ERROR', fg='red') + ': a single workspace must be specified (default or otherwise' ' ) when updating a timer.') return if not old_description and not old_tags: click.echo( click.style('ERROR', fg='red') + f': either description or tags must be specified when updating a timer.' ) return time_entries = context.obj['data']['time_entries'] if not time_entries: click.echo( click.style('ERROR', fg='red') + f': no time entries to update in this workspace({workspace.name}).' ) return if old_description: time_entries = TimeEntryFilter.filter_on_description( time_entries, old_description) if not time_entries: click.echo( click.style('WARNING', fg='yellow') + f': no time entries exist with the specified description.') return current_tags = context.obj['data']['tags'] if old_tags: current_tags = TagFilter.filter_on_names(current_tags, old_tags.split(',')) if not current_tags: click.echo( click.style('WARNING', fg='yellow') + f': no tags exist with the specified names.') return time_entries = TimeEntryFilter.filter_on_any_tags( time_entries, current_tags) if not time_entries: click.echo( click.style('WARNING', fg='yellow') + f': no time entries exist with the specified tags.') return # restore current_tags so we can filter it again if needed. current_tags = context.obj['data']['tags'] if len(time_entries) > 1 and not multiple: click.echo( click.style('ERROR', fg='red') + ': multiple timers found matching the criteria, but --multiple ' 'not specified.. please used the --multiple option or tighten ' 'the search criteria.') return projects = context.obj['data']['projects'] new_specified_project: Optional[Project] = None if new_project: projects = ProjectFilter.filter_on_name(projects, new_project) if not projects: click.echo( click.style('ERROR', fg='red') + f': no projects found with specified name.') return elif len(projects) > 1: click.echo( click.style('ERROR', fg='red') + f': multiple projects found with specified name. Update criteria ' f' to match a single project.') return new_specified_project = projects[0] additional_tags: Optional[List[Tag]] = None if add_tags: additional_tags = TagFilter.filter_on_names(current_tags, add_tags.split(',')) if not additional_tags: click.echo( click.style('ERROR', fg='red') + f': no tags found with the specified names in --add-tags.') return # restore current_tags so we can filter it again if needed. current_tags = context.obj['data']['tags'] removed_tags: Optional[List[Tag]] = None if remove_tags: removed_tags = TagFilter.filter_on_names(current_tags, remove_tags.split(',')) if not removed_tags: click.echo( click.style('ERROR', fg='red') + f': no tags found with the specified names in --remove-tags.') return entries: List[TimeEntry] = [] for time_entry in time_entries: time_entry_builder = TimeEntryBuilder(time_entry) if new_description: time_entry_builder.description(new_description) if new_project: time_entry_builder.project_identifier( new_specified_project.identifier) if add_tags: time_entry_builder.add_tags([tag.name for tag in additional_tags]) if remove_tags: time_entry_builder.remove_tags([tag.name for tag in removed_tags]) if new_start_time: time_entry_builder.start_time(dt=new_start_time) if new_duration: time_entry_builder.duration(new_duration) if new_stop_time: time_entry_builder.stop_time(dt=new_stop_time) entries.append(time_entry_builder.build()) caching = retrieve_cache_from_context(context.obj) commands = retrieve_commands_from_context(context.obj) try: updated_entries: List[TimeEntry] = [] for entry in entries: updated_entry = commands.update_completed_time_entry(entry) updated_entries.append(updated_entry) click.echo( click.style('SUCCESS', fg='green') + f': updated time entry({updated_entry.description}) in ' f' workspace({workspace.name})!') caching.update_time_entry_cache(updated_entries) except HTTPError as e: logging.getLogger(__name__).error(e) click.echo( click.style('ERROR', fg='red') + f': failed to update the timer(s). An exception has been logged;' ' check the logs for more information.')
def timer_delete(context: click.Context, description: str, multiple: bool, tags: str): workspace = retrieve_workspace_from_context(context.obj) if not workspace: # Workspace filter was not strict enough or there is no default # workspace configured. click.echo( click.style('ERROR', fg='red') + ': a single workspace must be specified (default or otherwise' ' ) when deleting a time entry.') return if not description and not tags: click.echo( click.style('ERROR', fg='red') + f': either description or tags must be specified when deleting a timer.' ) return time_entries = context.obj['data']['time_entries'] if description: time_entries = TimeEntryFilter.filter_on_description( time_entries, description) if not time_entries: click.echo( click.style('WARNING', fg='yellow') + f': no time entries exist with the specified description.') return current_tags = context.obj['data']['tags'] if tags: current_tags = TagFilter.filter_on_names(current_tags, tags.split(',')) if not current_tags: click.echo( click.style('WARNING', fg='yellow') + f': no tags exist with the specified names.') return time_entries = TimeEntryFilter.filter_on_any_tags( time_entries, current_tags) if not time_entries: click.echo( click.style('WARNING', fg='yellow') + f': no time entries exist with the specified tags.') return if len(time_entries) > 1 and not multiple: click.echo( click.style('ERROR', fg='red') + ': multiple time entries found matching the criteria, but --multiple ' 'not specified.. please use the --multiple option or tighten ' 'the search criteria.') return caching = retrieve_cache_from_context(context.obj) commands = retrieve_commands_from_context(context.obj) try: for entry in time_entries: commands.delete_time_entry(entry) caching.remove_time_entry_from_cache(entry) click.echo( click.style('SUCCESS', fg='green') + f': deleted timer({entry.description})!') except HTTPError as e: logging.getLogger(__name__).error(e) click.echo( click.style('ERROR', fg='red') + f': failed to delete the timer(s). An exception has been logged;' ' check the logs for more information.')
def timer_add(context: click.Context, description: str, start_time: datetime, stop_time: datetime, duration: int, tags: str): workspace = retrieve_workspace_from_context(context.obj) if not workspace: # Workspace filter was not strict enough or there is no default # workspace configured. click.echo( click.style('ERROR', fg='red') + ': a single workspace must be specified (default or otherwise' ' ) when adding a new timer.') return # Handling the error situations: if not stop_time and not duration: click.echo( click.style('ERROR', fg='red') + ': you must specify a stop time or duration when adding a new timer.' ) return time_entries = context.obj['data']['time_entries'] time_entries = TimeEntryFilter.filter_on_description( time_entries, description) if time_entries and description: click.echo( click.style('ERROR', fg='red') + ': a timer with this description already exists.') return time_entry_builder = TimeEntryBuilder() time_entry_builder.workspace_identifier(workspace.identifier) project = retrieve_project_from_context(context.obj) if project: time_entry_builder.project_identifier(project.identifier) if description: time_entry_builder.description(description) if start_time: time_entry_builder.start_time(dt=start_time) if stop_time: time_entry_builder.stop_time(dt=stop_time) if duration: time_entry_builder.duration(duration) elif stop_time: time_entry_builder.duration( int(stop_time.timestamp() - start_time.timestamp())) if tags: time_entry_builder.tags(tags.split(',')) caching = retrieve_cache_from_context(context.obj) commands = retrieve_commands_from_context(context.obj) try: time_entry = time_entry_builder.build() time_entry = commands.add_completed_time_entry(time_entry) caching.update_time_entry_cache([time_entry]) click.echo( click.style('SUCCESS', fg='green') + f': added new time entry to' f' workspace({workspace.name})!') except HTTPError as e: logging.getLogger(__name__).error(e) click.echo( click.style('ERROR', fg='red') + f': failed to add new time entry to workspace({workspace.name}).' ' An exception has been logged; check the logs for more information.' )