summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorchangiinlee <changjin9792@gmail.com>2024-02-04 23:38:13 +0900
committerchangiinlee <changjin9792@gmail.com>2024-02-04 23:38:13 +0900
commit60066bf8db0c5a4ffc58461677e75ec982fc33c6 (patch)
tree90a4fa3278ed79392efcf32ac5c9275f8040da57
parent83355ee31a6aba574f6c32037c1f5afe60366282 (diff)
✨ feat: Refactor task commands(addtask, showtask)
-rw-r--r--girok/api/task.py112
-rw-r--r--girok/commands/task/callbacks.py112
-rw-r--r--girok/commands/task/command.py472
-rw-r--r--girok/commands/task/display.py370
-rw-r--r--girok/commands/task/entity.py40
-rw-r--r--girok/commands/task/utils.py205
-rw-r--r--girok/constants.py23
-rw-r--r--girok/girok.py2
-rw-r--r--girok/utils/time.py31
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