diff options
author | changiinlee <changjin9792@gmail.com> | 2024-02-04 23:38:13 +0900 |
---|---|---|
committer | changiinlee <changjin9792@gmail.com> | 2024-02-04 23:38:13 +0900 |
commit | 60066bf8db0c5a4ffc58461677e75ec982fc33c6 (patch) | |
tree | 90a4fa3278ed79392efcf32ac5c9275f8040da57 | |
parent | 83355ee31a6aba574f6c32037c1f5afe60366282 (diff) |
✨ feat: Refactor task commands(addtask, showtask)
-rw-r--r-- | girok/api/task.py | 112 | ||||
-rw-r--r-- | girok/commands/task/callbacks.py | 112 | ||||
-rw-r--r-- | girok/commands/task/command.py | 472 | ||||
-rw-r--r-- | girok/commands/task/display.py | 370 | ||||
-rw-r--r-- | girok/commands/task/entity.py | 40 | ||||
-rw-r--r-- | girok/commands/task/utils.py | 205 | ||||
-rw-r--r-- | girok/constants.py | 23 | ||||
-rw-r--r-- | girok/girok.py | 2 | ||||
-rw-r--r-- | girok/utils/time.py | 31 |
9 files changed, 1367 insertions, 0 deletions
diff --git a/girok/api/task.py b/girok/api/task.py new file mode 100644 index 0000000..4ff4a0c --- /dev/null +++ b/girok/api/task.py @@ -0,0 +1,112 @@ +from typing import List, Optional +from urllib.parse import urljoin + +import requests +from requests import HTTPError + +from girok.api.category import get_category_id_by_path +from girok.api.entity import APIResponse +from girok.config.auth_handler import AuthHandler +from girok.constants import BASE_URL + + +def create_task( + name: str, + start_date: str, + start_time: Optional[str], + end_date: Optional[str], + end_time: Optional[str], + repetition_type: Optional[str], + repetition_end_date: Optional[str], + category_path: Optional[str], + tags: Optional[List[str]], + priority: Optional[str], + memo: Optional[str], +) -> APIResponse: + access_token = AuthHandler.get_access_token() + + # Resolve target category id + category_id = None + if category_path: + resp = get_category_id_by_path(category_path.split("/")) + if not resp.is_success: + return resp + category_id = resp.body["categoryId"] + + request_body = { + "categoryId": category_id, + "name": name, + "eventDate": {"startDate": start_date, "startTime": start_time, "endDate": end_date, "endTime": end_time}, + "repetition": {"repetitionType": repetition_type, "repetitionEndDate": repetition_end_date}, + "tags": tags, + "priority": priority, + "memo": memo, + } + + resp = requests.post( + url=urljoin(BASE_URL, "events"), headers={"Authorization": "Bearer " + access_token}, json=request_body + ) + + try: + resp.raise_for_status() + return APIResponse(is_success=True, body=resp.json()) + except HTTPError: + try: + error_body = resp.json() + error_message = error_body["message"] + except: + error_message = "Failed to create a new task" + + return APIResponse(is_success=False, error_message=error_message) + + +def get_all_tasks( + start_date: Optional[str] = "2000-01-01", + end_date: Optional[str] = "2050-01-01", + category_id: Optional[int] = None, + priority: Optional[str] = None, + tags: Optional[List[str]] = None, +): + params = { + "startDate": start_date, + "endDate": end_date, + "categoryId": category_id, + "priority": priority, + "tags": tags, + } + + access_token = AuthHandler.get_access_token() + resp = requests.get( + url=urljoin(BASE_URL, "events"), headers={"Authorization": "Bearer " + access_token}, params=params + ) + + try: + resp.raise_for_status() + return APIResponse(is_success=True, body=resp.json()) + except HTTPError: + try: + error_body = resp.json() + error_message = error_body["message"] + except: + error_message = "Failed to retrieve tasks" + + return APIResponse(is_success=False, error_message=error_message) + + +def remove_event(event_id: int): + access_token = AuthHandler.get_access_token() + resp = requests.delete( + url=urljoin(BASE_URL, f"events/{event_id}"), headers={"Authorization": "Bearer " + access_token} + ) + + try: + resp.raise_for_status() + return APIResponse(is_success=True) + except HTTPError: + try: + error_body = resp.json() + error_message = error_body["message"] + except: + error_message = "Failed to retrieve tasks" + + return APIResponse(is_success=False, error_message=error_message) diff --git a/girok/commands/task/callbacks.py b/girok/commands/task/callbacks.py new file mode 100644 index 0000000..364062a --- /dev/null +++ b/girok/commands/task/callbacks.py @@ -0,0 +1,112 @@ +import re +from datetime import datetime +from typing import Optional + +import typer + +from girok.commands.task.utils import decode_date_format, decode_time_format +from girok.constants import REPETITION_TYPE, TASK_PRIORITY + + +def allow_empty_category_callback(ctx: typer.Context, param: typer.CallbackParam, value: str): + if value is None: + return None + + if value == "/": + return value.rstrip("/") + + if not re.match("^([a-zA-Z0-9]+/)*[a-zA-Z0-9]+/?$", value): + raise typer.BadParameter("[Invalid category path] Category path must be in 'xx/yy/zz format.'") + + if value.endswith("/"): + value = value[:-1] + + if value == "none": + raise typer.BadParameter("Sorry, 'none' is a reserved category name.") + return value + + +def not_allow_empty_category_callback(ctx: typer.Context, param: typer.CallbackParam, value: str): + if value is None: + return None + + if not re.match("^([a-zA-Z0-9]+/)*[a-zA-Z0-9]+/?$", value): + raise typer.BadParameter("[Invalid category path] Category path must be in 'xx/yy/zz format.'") + + if value.endswith("/"): + value = value[:-1] + + if value == "none": + raise typer.BadParameter("Sorry, 'none' is a reserved category name.") + return value + + +def datetime_callback(ctx: typer.Context, param: typer.CallbackParam, value: str): + if value is None: + return None + """ + Check if + 1. xxx + 2. xxx@yyy + """ + if "@" not in value: + valid, iso_date_str = decode_date_format(value) + if not valid: + raise typer.BadParameter("Invalid date format") + return iso_date_str, None + else: + date_items = value.split("@") + if len(date_items) != 2: + raise typer.BadParameter("Invalid date format") + date_str, time_str = date_items + + # Validate date + valid, iso_date_str = decode_date_format(date_str) + if not valid: + raise typer.BadParameter("Invalid date format") + + # Validate time + valid, iso_time_str = decode_time_format(time_str) + if not valid: + raise typer.BadParameter("Invalid time format") + + return iso_date_str, iso_time_str + + +def tags_callback(ctx: typer.Context, param: typer.CallbackParam, value: Optional[str]): + if value is None: + return None + is_matched = bool(re.match("^[^,]+(,[^,]+)*$", value)) + if not is_matched: + raise typer.BadParameter("Invalid tags format. You must specify tags separated by comma such as 'A,B,C'") + return value + + +def priority_callback(ctx: typer.Context, param: typer.CallbackParam, value: str): + if value is None: + return None + + if value not in TASK_PRIORITY: + raise typer.BadParameter("Priority must be one of 'low', 'medium', 'high'") + + return TASK_PRIORITY[value] + + +def date_callback(ctx: typer.Context, param: typer.CallbackParam, value: str): + if value is None: + return None + + valid, iso_date_str = decode_date_format(value) + if not valid: + raise typer.BadParameter("Invalid date format") + return iso_date_str + + +def valid_integer_callback(ctx: typer.Context, param: typer.CallbackParam, value: int): + if value is None: + return None + + if value <= 0: + raise typer.BadParameter("The time window must be a positive integer") + + return value diff --git a/girok/commands/task/command.py b/girok/commands/task/command.py new file mode 100644 index 0000000..143a171 --- /dev/null +++ b/girok/commands/task/command.py @@ -0,0 +1,472 @@ +import calendar +from datetime import datetime, timedelta +from typing import List, Optional + +import typer +from rich import print +from typing_extensions import Annotated + +import girok.api.category as category_api +import girok.api.task as task_api +from girok.commands.task.callbacks import ( + allow_empty_category_callback, + date_callback, + datetime_callback, + not_allow_empty_category_callback, + priority_callback, + tags_callback, + valid_integer_callback, +) +from girok.commands.task.display import display_events_by_list, display_events_by_tree +from girok.commands.task.entity import Category, Event, EventDate, Repetition +from girok.commands.task.utils import decode_date_format, validate_start_end_window +from girok.constants import EVENT_IDS_CACHE_PATH, REPETITION_TYPE, DisplayBoxType +from girok.utils.display import center_print +from girok.utils.json_utils import read_json +from girok.utils.time import build_date_info, convert_date_obj_to_iso_date_str + +app = typer.Typer(rich_markup_mode="rich") + + +@app.command( + "addtask", + help="[yellow]Add[/yellow] a new task", + rich_help_panel=":fire: [bold yellow1]Task Commands[/bold yellow1]", +) +def add_task( + name: Annotated[str, typer.Argument(help="Task name")], + start_datetime: Annotated[ + str, typer.Option("-d", "--date", help="[yellow]Task start datetime[/yellow]", callback=datetime_callback) + ], + end_datetime: Annotated[ + Optional[str], + typer.Option("-e", "--end", help="[yellow]Task end datetime[/yellow]", callback=datetime_callback), + ] = None, + repetition: Annotated[ + Optional[str], + typer.Option( + "-r", + "--repetition", + help="[yellow]Task repetition type. One of 'daily', 'weekly', 'monthly', 'yearly'[/yellow]", + ), + ] = None, + category_path: Annotated[ + Optional[str], + typer.Option( + "-c", + "--category", + help="[yellow]Category path - xx/yy/zz..[/yellow]", + callback=allow_empty_category_callback, + ), + ] = None, + tags: Annotated[ + Optional[str], + typer.Option( + "-t", + "--tag", + help="[yellow]Tags[/yellow]. Multiple tags must be provided in 'A,B,C' format.", + callback=tags_callback, + ), + ] = None, + priority: Annotated[ + Optional[str], typer.Option("-p", "--priority", help="[yellow]Priority[/yellow]", callback=priority_callback) + ] = None, + memo: Annotated[Optional[str], typer.Option("-m", "--memo", help="[yellow]Memo[/yellow]")] = None, +): + """ + Validate time combination. The possible combinations are: + 1. start_date + 2. start_date, start_time + 3. start_date, end_date + 4. start_date, start_time, end_date, end_time + """ + start_date, start_time = start_datetime + end_date, end_time = None, None + if end_datetime: + end_date, end_time = end_datetime + + valid, err_msg = validate_start_end_window(start_date, start_time, end_date, end_time) + if not valid: + raise typer.BadParameter(err_msg) + + # Convert tags to list + if tags: + tags = tags.split(",") + + # Validate repetition + repetition_type = None + repetition_end_date = None + if repetition: + # Repetition is only allowed for single-day event + if (start_date and end_date) and (start_date != end_date): + raise typer.BadParameter("Repetition is only allowed for single-day event") + + if "@" not in repetition: # daily + if repetition not in REPETITION_TYPE: + raise typer.BadParameter("Repetition type must be one of 'daily', 'weekly', 'monthly', 'yearly'") + repetition_type = repetition + else: # daily@5/14 + repetition_items = repetition.split("@") + if len(repetition_items) != 2: + raise typer.BadParameter("Invalid repetition input format") + + repetition_type, repetition_end_date_str = repetition_items + if repetition_type not in REPETITION_TYPE: + raise typer.BadParameter("Repetition type must be one of 'daily', 'weekly', 'monthly', 'yearly'") + + valid, iso_date_str = decode_date_format(repetition_end_date_str) + if not valid: + raise typer.BadParameter("Invalid repetition end date format") + + repetition_end_date = iso_date_str + + # Repetition end date must be greater than start_date + repetition_end_date_obj = datetime.strptime(repetition_end_date, "%Y-%m-%d").date() + start_date_obj = datetime.strptime(start_date, "%Y-%m-%d").date() + if repetition_end_date_obj <= start_date_obj: + raise typer.BadParameter("Repetition end date must be greater than start date") + + repetition_type = REPETITION_TYPE[repetition_type] + + resp = task_api.create_task( + name=name, + start_date=start_date, + start_time=start_time, + end_date=end_date, + end_time=end_time, + repetition_type=repetition_type, + repetition_end_date=repetition_end_date, + category_path=category_path, + tags=tags, + priority=priority, + memo=memo, + ) + if not resp.is_success: + center_print(resp.error_message, DisplayBoxType.ERROR) + raise typer.Exit() + + # Display Tasks + created_event_id = resp.body["eventId"] + resp = task_api.get_all_tasks() + if not resp.is_success: + center_print(resp.error_message, DisplayBoxType.ERROR) + raise typer.Exit() + + events = resp.body["events"] + event_entities = map_to_event_entities(events) + display_events_by_list(event_entities, highlight_event_id=created_event_id, highlight_action="highlight") + + +@app.command( + "showtask", + help="[yellow]View[/yellow] tasks with options", + rich_help_panel=":fire: [bold yellow1]Task Commands[/bold yellow1]", +) +def showtask( + category_path: Annotated[ + Optional[str], + typer.Option( + "-c", + "--category", + help="[yellow]Category path - xx/yy/zz..[/yellow]", + callback=not_allow_empty_category_callback, + ), + ] = None, + tags: Annotated[ + Optional[str], + typer.Option( + "-t", + "--tag", + help="[yellow]Tags[/yellow]. Multiple tags must be provided in 'A,B,C' format.", + callback=tags_callback, + ), + ] = None, + priority: Annotated[ + Optional[str], typer.Option("-p", "--priority", help="[yellow]Priority[/yellow]", callback=priority_callback) + ] = None, + exact_date: Annotated[ + Optional[str], typer.Option("-e", "--exact", help="[yellow]Exact Deadline[/yellow]", callback=date_callback) + ] = None, + within_days: Annotated[ + Optional[int], + typer.Option( + "-d", + "--day", + help="Show tasks due [yellow]within the specified days[/yellow]", + callback=valid_integer_callback, + ), + ] = None, + within_weeks: Annotated[ + Optional[int], + typer.Option( + "-w", + "--week", + help="Show tasks due [yellow]within the specified weeks[/yellow]", + callback=valid_integer_callback, + ), + ] = None, + within_months: Annotated[ + Optional[int], + typer.Option( + "-m", + "--month", + help="Show tasks due [yellow]within the specified months[/yellow]", + callback=valid_integer_callback, + ), + ] = None, + within_this_week: Annotated[ + Optional[bool], + typer.Option( + "-tw", + "--this-week", + help="Show tasks due [yellow]within this week[/yellow]", + ), + ] = None, + within_next_week: Annotated[ + Optional[bool], + typer.Option( + "-nw", + "--next-week", + help="Show tasks due [yellow]within next week[/yellow]", + ), + ] = None, + within_this_month: Annotated[ + Optional[bool], + typer.Option( + "-tm", + "--this-month", + help="Show tasks due [yellow]within this month[/yellow]", + ), + ] = None, + within_next_month: Annotated[ + Optional[bool], + typer.Option( + "-nm", + "--next-month", + help="Show tasks due [yellow]within next month[/yellow]", + ), + ] = None, + today: Annotated[ + Optional[bool], + typer.Option( + "-tdy", + "--today", + help="Show tasks due [yellow]today[/yellow]", + ), + ] = None, + tomorrow: Annotated[ + Optional[bool], + typer.Option( + "-tmr", + "--tomorrow", + help="Show tasks due [yellow]tomorrow[/yellow]", + ), + ] = None, + urgent: Annotated[ + Optional[bool], typer.Option("-u", "--urgent", help="Show [yellow]urgent[/yellow] tasks (due within 3 days)") + ] = None, + tree_view: Annotated[ + Optional[bool], + typer.Option( + '--tree', + help="[yellow]Show tasks in a tree view[/yellow]" + ) + ] = None +): + # Resolve start_date and end_date + """ + 1. exact_date -> [start, end] + 2. within_days -> [-inf, end] + 3. within_weeks -> [-inf, end] + 4. within_months -> [-inf, month] + 5. within_this_week -> [-inf, end] + 6. within_next_week -> [-inf, end] + 7. within_this_month -> [-inf, end] + 8. within_next_month -> [-inf, end] + 9. today -> [start, end] + 10. tomorrow -> [start, end] + 11. urgent -> [today, end] + -> else -> [-inf, inf] + """ + date_options_cnt = sum( + [ + opt is not None + for opt in [ + exact_date, + within_days, + within_weeks, + within_months, + today, + tomorrow, + within_this_week, + within_next_week, + within_this_month, + within_next_month, + ] + ] + ) + if date_options_cnt > 1: + raise typer.BadParameter("You can specify only one date option.") + + start_date, end_date = "2000-01-01", convert_date_obj_to_iso_date_str(datetime.now() + timedelta(days=365)) + if exact_date: + start_date, end_date = exact_date, exact_date + if within_days: + end_date = convert_date_obj_to_iso_date_str(datetime.now() + timedelta(days=within_days - 1)) + if within_weeks: + end_date = convert_date_obj_to_iso_date_str(datetime.now() + timedelta(days=7 * within_weeks)) + if within_months: + end_date = convert_date_obj_to_iso_date_str(datetime.now() + timedelta(days=30 * within_months)) + if within_this_week: + delta = 6 - datetime.today().weekday() + end_date = convert_date_obj_to_iso_date_str(datetime.today() + timedelta(days=delta)) + if within_next_week: + next_week = datetime.today() + timedelta(days=7) + delta = 6 - next_week.weekday() + end_date = convert_date_obj_to_iso_date_str(next_week + timedelta(days=delta)) + if within_this_month: + today_date = datetime.today() + _, last_day = calendar.monthrange(today_date.year, today_date.month) + end_date = convert_date_obj_to_iso_date_str(datetime(today_date.year, today_date.month, last_day)) + if within_next_month: + today_date = datetime.today() + if today_date.month == 12: + first_day_next_month = datetime(today_date.year + 1, 1, 1) + else: + first_day_next_month = datetime(today_date.year, today_date.month + 1, 1) + _, last_day = calendar.monthrange(first_day_next_month.year, first_day_next_month.month) + end_date = convert_date_obj_to_iso_date_str( + datetime(first_day_next_month.year, first_day_next_month.month, last_day) + ) + if today: + start_date, end_date = convert_date_obj_to_iso_date_str(datetime.now()), convert_date_obj_to_iso_date_str( + datetime.now() + ) + if tomorrow: + start_date, end_date = convert_date_obj_to_iso_date_str( + datetime.now() + timedelta(1) + ), convert_date_obj_to_iso_date_str(datetime.now() + timedelta(1)) + if urgent: + start_date, end_date = convert_date_obj_to_iso_date_str(datetime.now()), convert_date_obj_to_iso_date_str( + datetime.now() + timedelta(days=2) + ) + + # Resolve category id + category_id = None + if category_path: + resp = category_api.get_category_id_by_path(category_path.split("/")) + if not resp.is_success: + center_print(resp.error_message, DisplayBoxType.ERROR) + raise typer.Exit() + category_id = resp.body["categoryId"] + + if tags: + tags = tags.split("/") + + resp = task_api.get_all_tasks( + start_date=start_date, end_date=end_date, category_id=category_id, priority=priority, tags=tags + ) + if not resp.is_success: + center_print(resp.error_message, DisplayBoxType.ERROR) + raise typer.Exit() + + events = resp.body["events"] + + # Display Events + event_entities = map_to_event_entities(events) + center_print(build_date_info(datetime.now()), DisplayBoxType.TITLE) + if tree_view: + resp = category_api.get_all_categories() + if not resp.is_success: + center_print("Unable to fetch categories", DisplayBoxType.ERROR) + raise typer.Exit() + categories = resp.body['rootCategories'] + display_events_by_tree(categories, event_entities) + else: + display_events_by_list(event_entities) + + +@app.command( + "done", + help="[red]Delete[/red] a task", + rich_help_panel=":fire: [bold yellow1]Task Commands[/bold yellow1]", +) +def remove_event( + event_id: Annotated[int, typer.Argument(help="[yellow]Task ID[/yellow] to be deleted")], + force: Annotated[Optional[bool], typer.Option("-y", "--yes", help="Don't show the confirmation message")] = False, +): + try: + cache = read_json(EVENT_IDS_CACHE_PATH) + except: + center_print("First type 'showtask' command to retrieve task ids", DisplayBoxType.ERROR) + raise typer.Exit() + + if str(event_id) not in cache: + center_print(f"Task id {event_id} is not found. Please enter 'showtask' command to view task ids.", DisplayBoxType.ERROR) + raise typer.Exit() + cached_event = cache[str(event_id)] + + target_event_id = cached_event["id"] + target_event_name = cached_event["name"] + + if not force: + done_confirm = typer.confirm(f"Are you sure to delete task [{target_event_name}]?") + if not done_confirm: + raise typer.Exit() + + # Display events + resp = task_api.get_all_tasks() + if not resp.is_success: + center_print(resp.error_message, DisplayBoxType.ERROR) + raise typer.Exit() + + events = resp.body["events"] + event_entities = map_to_event_entities(events) + + # Remove events + resp = task_api.remove_event(target_event_id) + if not resp.is_success: + center_print(resp.error_message, DisplayBoxType.ERROR) + raise typer.Exit() + + center_print("Task was successfully deleted!", DisplayBoxType.SUCCESS) + display_events_by_list(event_entities, highlight_event_id=target_event_id, highlight_action="delete") + + +def map_to_event_entities(events: List[dict]) -> List[Event]: + event_entities: List[Event] = [] + for event in events: + event_entity = Event( + id=event["id"], + name=event["name"], + color_str=event["color"], + tags=event["tags"], + priority=event["priority"], + memo=event["memo"], + event_date=EventDate( + start_date=event["eventDate"]["startDate"], + start_time=event["eventDate"]["startTime"], + end_date=event["eventDate"]["endDate"], + end_time=event["eventDate"]["endTime"], + ), + repetition=Repetition( + repetition_type=event["repetition"]["repetitionType"], + repetition_end_date=event["repetition"]["repetitionEndDate"], + ), + category_path=[Category(id=c["categoryId"], name=c["categoryName"]) for c in event["categoryPath"]], + ) + + event_entities.append(event_entity) + + def sort_key(event: Event): + # Handle None values for start_time and end_time by replacing them with "00:00" + start_time = event.event_date.start_time if event.event_date.start_time else "00:00" + end_time = event.event_date.end_time if event.event_date.end_time else "00:00" + # Handle None for end_date by using start_date + end_date = event.event_date.end_date if event.event_date.end_date else event.event_date.start_date + + return (event.event_date.start_date, start_time, end_date, end_time) + + event_entities.sort(key=sort_key) + return event_entities + diff --git a/girok/commands/task/display.py b/girok/commands/task/display.py new file mode 100644 index 0000000..e0406e7 --- /dev/null +++ b/girok/commands/task/display.py @@ -0,0 +1,370 @@ +import calendar +from datetime import datetime +from typing import List, Literal, Optional + +from rich import box, print +from rich.align import Align +from rich.tree import Tree +from rich.console import Console +from rich.style import Style +from rich.table import Column, Table +from rich.text import Text + +from girok.commands.task.entity import Category, Event, EventDate, Repetition +from girok.commands.task.utils import cache_event_ids +from girok.constants import ( + TABLE_DATETIME_COLOR, + TABLE_DEFAULT_TEXT_COLOR, + TABLE_EVENT_DELETED_COLOR, + TABLE_EVENT_HIGHLIGHT_COLOR, + TABLE_EVENT_NAME_COLOR, + TABLE_HEADER_TEXT_COLOR, + Emoji, + CATEGORY_COLOR_PALETTE, + DEFAULT_CATEGORY_COLOR, + EVENT_TREE_EVENT_COLOR, + EVENT_TREE_CATEGORY_COLOR, + EVENT_TREE_DATETIME_COLOR +) +from girok.utils.time import convert_iso_date_str_to_date_obj, get_day_offset + + +def display_events_by_list( + events: List[Event], + highlight_event_id: Optional[int] = None, + highlight_action: Literal["highlight", "delete"] = None, +): + """ + id + name + category path + start datetime (8 December 2023, 7:30PM) + end datetime (8 December 2023, 7:30PM) + Tags + Priority + Memo + Repetition + Repetition End Date + """ + num_events = len(events) + table = Table( + Column( + header="ID", + justify="center", + header_style=Style(color=TABLE_HEADER_TEXT_COLOR), + style=Style(color=TABLE_EVENT_NAME_COLOR), + ), + Column( + header="Name", header_style=Style(color=TABLE_HEADER_TEXT_COLOR), style=Style(color=TABLE_EVENT_NAME_COLOR) + ), + Column( + header="Category", + justify="center", + header_style=Style(color=TABLE_HEADER_TEXT_COLOR), + style=Style(color=TABLE_DEFAULT_TEXT_COLOR), + ), + Column( + header="Start date", + justify="center", + header_style=Style(color=TABLE_HEADER_TEXT_COLOR), + ), + Column( + header="End date", + justify="center", + header_style=Style(color=TABLE_HEADER_TEXT_COLOR), + ), + Column( + header="Repeat", + justify="center", + header_style=Style(color=TABLE_HEADER_TEXT_COLOR), + ), + Column( + header="Tags", + justify="center", + header_style=Style(color=TABLE_HEADER_TEXT_COLOR), + ), + Column( + header="Priority", + justify="center", + header_style=Style(color=TABLE_HEADER_TEXT_COLOR), + ), + Column(header="Memo", header_style=Style(color=TABLE_HEADER_TEXT_COLOR)), + Column(header="Remaining", justify="center", header_style=Style(color=TABLE_HEADER_TEXT_COLOR)), + box=box.SIMPLE_HEAVY, + show_lines=False if num_events > 15 else True, + border_style=Style(color="#D7E1C9", bold=True), + ) + + if highlight_event_id: + highlight_color = TABLE_EVENT_HIGHLIGHT_COLOR if highlight_action == "highlight" else TABLE_EVENT_DELETED_COLOR + else: + highlight_color = None + + event_ids_cache = {} + for idx, event in enumerate(events): + # Cache event id + event_ids_cache[idx + 1] = {"id": event.id, "name": event.name} + + # Build category path + if event.category_path: + category_str = "/".join([c.name for c in event.category_path]) + else: + category_str = "-" + + # Build start time + start_datetime_str = build_datetime_str(event.event_date.start_date, event.event_date.start_time) + + # Build end time + end_datetime_str = build_datetime_str(event.event_date.end_date, event.event_date.end_time) + + # Build tags + tag_str = ", ".join(event.tags) if event.tags else "-" + + # Build repetition + repetition_type = event.repetition.repetition_type + repetition_end_date = event.repetition.repetition_end_date + if not repetition_type: + repetition_str = "-" + else: + repetition_str = repetition_type + if repetition_end_date: + repetition_str += f" until {repetition_end_date}" + + # Remaining time + remaining_days = get_day_offset(datetime.now(), convert_iso_date_str_to_date_obj(event.event_date.start_date)) + remaining_days_str = ( + f"{remaining_days} days left" if remaining_days > 0 else f"{abs(remaining_days)} days passed" + ) + + # Build priority + priority_str = event.priority if event.priority else "-" + + # Build Memo + if event.memo: + memo_str = event.memo if len(event.memo) <= 30 else event.memo[:30] + "..." + else: + memo_str = "-" + + table.add_row( + Text(str(idx + 1), style=Style(color=TABLE_DEFAULT_TEXT_COLOR)), # id + Text.assemble( # name + Text(Emoji.CIRCLE, style=Style(color=event.color_hex)), # circle emoji + " ", + Text( + event.name, + style=Style( + color=highlight_color if highlight_event_id == event.id else TABLE_DEFAULT_TEXT_COLOR, bold=True + ), + ), + ), + Text( # category path + category_str, + style=Style(color=highlight_color if highlight_event_id == event.id else TABLE_DEFAULT_TEXT_COLOR), + ), + Text( # start datetime + start_datetime_str, + style=Style(color=highlight_color if highlight_event_id == event.id else TABLE_DATETIME_COLOR), + ), + Text( # end datetime + end_datetime_str, + style=Style(color=highlight_color if highlight_event_id == event.id else TABLE_DATETIME_COLOR), + ), + Text( + repetition_str, + style=Style(color=highlight_color if highlight_event_id == event.id else TABLE_DEFAULT_TEXT_COLOR), + ), + Text( # Tags + tag_str, + style=Style(color=highlight_color if highlight_event_id == event.id else TABLE_DEFAULT_TEXT_COLOR), + ), + Text( # priority + priority_str, + style=Style(color=highlight_color if highlight_event_id == event.id else TABLE_DEFAULT_TEXT_COLOR), + ), + Text( + memo_str, + style=Style(color=highlight_color if highlight_event_id == event.id else TABLE_DEFAULT_TEXT_COLOR), + ), + Text( + remaining_days_str, + style=Style(color=highlight_color if highlight_event_id == event.id else TABLE_DEFAULT_TEXT_COLOR), + ), + ) + + cache_event_ids(event_ids_cache) + print(Align(table, align="center")) + + +def display_events_by_tree( + categories: List[dict], + events: List[Event], + highlight_event_id: Optional[int] = None, + highlight_action: Literal["highlight", "delete"] = None, +): + event_ids_cache = {} + category_tree = { + "No Category": { + "subcategories": {}, + "events": [], + "color": DEFAULT_CATEGORY_COLOR + }, + } + + # Build category tree + for category in categories: + build_category_tree(category_tree, category) + + for event in events: + category_path = event.category_path + if not category_path: + category_tree['No Category']['events'].append(event) + else: + recursively_create_category_path(category_tree, category_path, event) + + tree_obj = Tree("") + for category_name, category_tree_dict in category_tree.items(): + subtree_obj = get_event_subtree( + category_tree=category_tree_dict, + current_category_name=category_name, + event_ids_cache=event_ids_cache, + highlight_event_id=highlight_event_id, + highlight_action=highlight_action + ) + tree_obj.add(subtree_obj) + + # Cache ids + cache_event_ids(event_ids_cache) + print(tree_obj) + + +def build_category_tree(category_tree: dict, category: dict): + category_tree[category['name']] = { + "subcategories": {}, + "events": [], + "color": CATEGORY_COLOR_PALETTE[category['color']] + } + + for child in category['children']: + build_category_tree(category_tree[category['name']]['subcategories'], child) + + + +def get_event_subtree( + category_tree: dict, + current_category_name: str, + event_ids_cache: dict, + highlight_event_id: Optional[int] = None, + highlight_action: Literal["highlight", "delete"] = None, +): + current_node_text = Text.assemble( + Text( + Emoji.CIRCLE, + style=Style(color=category_tree['color']) + ), + " ", + Text( + f"{current_category_name}", + style=Style(color=TABLE_HEADER_TEXT_COLOR) + ) + ) + tree_obj = T |