From 83355ee31a6aba574f6c32037c1f5afe60366282 Mon Sep 17 00:00:00 2001 From: changiinlee Date: Sat, 3 Feb 2024 15:41:14 +0900 Subject: =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refact:=20Refactor=20category=20co?= =?UTF-8?q?mmands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- girok/api/category.py | 149 ++++++++++++++++++++++++- girok/commands/category/command.py | 218 ++++++++++++++++++++++++++++++------- girok/commands/category/util.py | 17 ++- girok/config/auth_handler.py | 2 +- girok/constants.py | 5 +- girok/girok.py | 2 +- girok/utils/display.py | 4 +- 7 files changed, 343 insertions(+), 54 deletions(-) diff --git a/girok/api/category.py b/girok/api/category.py index 4821ddb..4eef4a6 100644 --- a/girok/api/category.py +++ b/girok/api/category.py @@ -1,3 +1,4 @@ +from typing import Optional from urllib.parse import urljoin import requests @@ -26,14 +27,24 @@ def get_all_categories() -> APIResponse: error_message = "Failed to get categories" return APIResponse(is_success=False, error_message=error_message) - + def create_category(category_path: str, color: str) -> APIResponse: access_token = AuthHandler.get_access_token() + + category_path_list = category_path.split("/") + new_category_name = category_path_list[-1] + + # Resolve parent category's id + parent_category_id_resp = get_category_id_by_path(category_path_list[:-1]) + if not parent_category_id_resp.is_success: + return APIResponse(is_success=False, error_message=parent_category_id_resp.error_message) + + parent_category_id = parent_category_id_resp.body["categoryId"] resp = requests.post( - url=urljoin(BASE_URL, "categories/path"), + url=urljoin(BASE_URL, "categories"), headers={"Authorization": "Bearer " + access_token}, - json={"path": category_path.split("/"), "color": color} + json={"parentId": parent_category_id, "name": new_category_name, "color": color}, ) try: @@ -42,10 +53,136 @@ def create_category(category_path: str, color: str) -> APIResponse: except HTTPError: try: error_body = resp.json() - print(error_body) + error_code = error_body["errorCode"] error_message = error_body["message"] + + if error_code == "DUPLICATE_CATEGORY": + parent_category_path_str = ( + "/" if not category_path_list[:-1] else "/".join(category_path_list[:-1]) + "/" + ) + error_message = f"Duplicate Category: '{parent_category_path_str}' already has '{new_category_name}'" except: error_message = "Failed to create a new category" - + + return APIResponse(is_success=False, error_message=error_message) + + +def remove_category(category_path: str) -> APIResponse: + access_token = AuthHandler.get_access_token() + + category_path_list = category_path.split("/") + category_id_resp = get_category_id_by_path(category_path_list) + if not category_id_resp.is_success: + return APIResponse(is_success=False, error_message=category_id_resp.error_message) + + category_id = category_id_resp.body["categoryId"] + resp = requests.delete( + url=urljoin(BASE_URL, f"categories/{category_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 remove a category" + + return APIResponse(is_success=False, error_message=error_message) + + +def update_category(category_path: str, new_name: Optional[str] = None, new_color: Optional[str] = None) -> APIResponse: + access_token = AuthHandler.get_access_token() + + category_path_list = category_path.split("/") + category_id_resp = get_category_id_by_path(category_path_list) + if not category_id_resp.is_success: + return category_id_resp + + category_id = category_id_resp.body["categoryId"] + body = {} + if new_name: + body["newName"] = new_name + if new_color: + body["color"] = new_color + + resp = requests.patch( + url=urljoin(BASE_URL, f"categories/{category_id}"), + headers={"Authorization": "Bearer " + access_token}, + json=body, + ) + + 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 rename a category" + + return APIResponse(is_success=False, error_message=error_message) + + +def move_category(path: str, new_parent_path: str) -> APIResponse: + # girok mvcat A/B/C D/E + access_token = AuthHandler.get_access_token() + + path_list = path.split("/") + new_parent_path_list = new_parent_path.split("/") if new_parent_path else [] + + # 1. Get the category id + resp = get_category_id_by_path(path_list) + if not resp.is_success: + return resp + category_id = resp.body["categoryId"] + + # 2. Get target parent's id + resp = get_category_id_by_path(new_parent_path_list) + if not resp.is_success: + return resp + new_parent_category_id = resp.body["categoryId"] + + resp = requests.patch( + url=urljoin(BASE_URL, f"categories/{category_id}/parent"), + headers={"Authorization": "Bearer " + access_token}, + json={"newParentId": new_parent_category_id}, + ) + + 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 move a category" + return APIResponse(is_success=False, error_message=error_message) + + +def get_category_id_by_path(path_list: list[str]) -> APIResponse: + if len(path_list) == 0: + return APIResponse(is_success=True, body={"categoryId": None}) + + access_token = AuthHandler.get_access_token() + resp = requests.get( + url=urljoin(BASE_URL, "categories/id-by-path"), + headers={"Authorization": "Bearer " + access_token}, + params={"path": path_list}, + ) + + 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 = f"Failed to get a category id of '{path_list}'" return APIResponse(is_success=False, error_message=error_message) - diff --git a/girok/commands/category/command.py b/girok/commands/category/command.py index 8486587..a037ec0 100644 --- a/girok/commands/category/command.py +++ b/girok/commands/category/command.py @@ -1,17 +1,28 @@ import re -from typing_extensions import Annotated + import typer from rich import print from rich.console import Console from rich.markdown import Markdown -from rich.tree import Tree -from rich.text import Text from rich.style import Style +from rich.text import Text +from rich.tree import Tree +from typing_extensions import Annotated import girok.api.category as category_api -from girok.commands.category.util import display_categories_tree, get_next_category_color -from girok.constants import DisplayBoxType, CATEGORY_COLOR_PALETTE, Emoji, DEFAULT_CATEGORY_TEXT_COLOR, DisplayArrowType -from girok.utils.display import center_print, arrow_print +from girok.commands.category.util import ( + display_categories_tree, + display_category_color_palette, + get_next_category_color, +) +from girok.constants import ( + CATEGORY_COLOR_PALETTE, + DEFAULT_CATEGORY_TEXT_COLOR, + DisplayArrowType, + DisplayBoxType, + Emoji, +) +from girok.utils.display import arrow_print, center_print app = typer.Typer(rich_markup_mode="rich") console = Console() @@ -21,10 +32,11 @@ def category_callback(ctx: typer.Context, param: typer.CallbackParam, value: str if value is None: return None + if ctx.command.name == "mvcat" and param.name == "new_parent_path" and 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.'" - ) + raise typer.BadParameter("[Invalid category path] Category path must be in 'xx/yy/zz format.'") if value.endswith("/"): value = value[:-1] @@ -56,48 +68,174 @@ def show_categories(): rich_help_panel=":file_folder: [bold yellow1]Category Commands[/bold yellow1]", ) def add_category( - category_path: Annotated[str, typer.Argument( - ..., - help="[yellow]Category path - xx/yy/zz..[/yellow]", - callback=category_callback, - )], - color: Annotated[str, typer.Option( - "-c", "--color", - help="[yellow]Color[/yellow] for category" - )] = None + category_path: Annotated[ + str, + typer.Argument( + ..., + help="[yellow]Category path - xx/yy/zz..[/yellow]", + callback=category_callback, + ), + ], + color: Annotated[str, typer.Option("-c", "--color", help="[yellow]Color[/yellow] for category")] = None, ): # Resolve color if color: + if len(category_path.split("/")) != 1: + arrow_print("You cannot specify non top-level category color", DisplayArrowType.ERROR) + raise typer.Exit() + if color not in CATEGORY_COLOR_PALETTE: arrow_print("Unsupported category color\n", DisplayArrowType.ERROR) - tree = Tree("Supported category colors") - for color_name, hex in CATEGORY_COLOR_PALETTE.items(): - circle_text = Text(text=Emoji.CIRCLE, style=Style(color=CATEGORY_COLOR_PALETTE[color_name])) - category_name_text = Text( - text=f"{color_name}", - style=Style(color=DEFAULT_CATEGORY_TEXT_COLOR) - ) - item_text = Text.assemble(circle_text, " ", category_name_text) - tree.add(item_text) - console.print(tree) + display_category_color_palette() raise typer.Exit() else: - # If color is not passed, automatically assign the color from the palette - color = get_next_category_color() - - print(category_path) - print(color) + # If color is not passed and top-level category, automatically assign the color from the palette + if len(category_path.split("/")) == 1: + color = get_next_category_color() + # Create a new category resp = category_api.create_category(category_path, color) - print(resp.is_success) - print(resp.error_message) - - - - + if not resp.is_success: + center_print(resp.error_message, DisplayBoxType.ERROR) + raise typer.Exit() + + resp = category_api.get_all_categories() + if not resp.is_success: + center_print(resp.error_message, DisplayBoxType.ERROR) + raise typer.Exit() + + center_print("Event Categories", DisplayBoxType.TITLE) + root_categories: list[dict] = resp.body["rootCategories"] + display_categories_tree(root_categories, category_path) + + +@app.command( + "rmcat", + help="[red]Remove[/red] a category", + rich_help_panel=":file_folder: [bold yellow1]Category Commands[/bold yellow1]", +) +def remove_category( + category_path: Annotated[ + str, + typer.Argument( + ..., + help="[yellow]Category path - xx/yy/zz..[/yellow]", + callback=category_callback, + ), + ], + force_yes: Annotated[bool, typer.Option("-y", "--yes", help="[yellow]Ignore confirm message[/yellow]")] = False, +): + if not force_yes: + confirm_rm = typer.confirm( + f"[WARNING] Are you sure to delete '{category_path}'?\nAll the subcategories and tasks will also be deleted." + ) + if not confirm_rm: + raise typer.Exit() + + resp = category_api.remove_category(category_path) + if not resp.is_success: + center_print(resp.error_message, DisplayBoxType.ERROR) + raise typer.Exit() + + resp = category_api.get_all_categories() + if not resp.is_success: + center_print(resp.error_message, DisplayBoxType.ERROR) + raise typer.Exit() + + center_print("Event Categories", DisplayBoxType.TITLE) + root_categories: list[dict] = resp.body["rootCategories"] + display_categories_tree(root_categories, category_path) + + +@app.command( + "upcat", + help="[green]Rename[/green] a category", + rich_help_panel=":file_folder: [bold yellow1]Category Commands[/bold yellow1]", +) +def rename_category( + category_path: Annotated[ + str, + typer.Argument( + help="[yellow]Category path - xx/yy/zz..[/yellow]", + callback=category_callback, + ), + ], + new_name: Annotated[str, typer.Option("-n", "--name", help="[yellow]New category name[/yellow]")] = None, + new_color: Annotated[str, typer.Option("-c", "--color", help="[yellow]New category color[/yellow]")] = None, +): + if new_name is None and new_color is None: + arrow_print("Please provide fields to update", DisplayArrowType.ERROR) + raise typer.Exit() + + # Resolve color + if new_color: + if len(category_path.split("/")) != 1: + arrow_print("You cannot update non top-level category color", DisplayArrowType.ERROR) + raise typer.Exit() + if new_color not in CATEGORY_COLOR_PALETTE: + arrow_print("Unsupported category color\n", DisplayArrowType.ERROR) + display_category_color_palette() + raise typer.Exit() - + resp = category_api.update_category(category_path, new_name, new_color) + if not resp.is_success: + center_print(resp.error_message, DisplayBoxType.ERROR) + raise typer.Exit() + resp = category_api.get_all_categories() + if not resp.is_success: + center_print(resp.error_message, DisplayBoxType.ERROR) + raise typer.Exit() + center_print("Event Categories", DisplayBoxType.TITLE) + root_categories: list[dict] = resp.body["rootCategories"] + name = new_name if new_name else category_path.split("/")[-1] + new_path = "/".join(category_path.split("/")[:-1] + [name]) + display_categories_tree(root_categories, new_path) + +@app.command( + "mvcat", + help="[yellow]Move[/yellow] a category to under category", + rich_help_panel=":file_folder: [bold yellow1]Category Commands[/bold yellow1]", +) +def move_category( + path: Annotated[ + str, + typer.Argument( + help="[yellow]Category path - xx/yy/zz..[/yellow]", + callback=category_callback, + ), + ], + new_parent_path: Annotated[ + str, + typer.Argument( + help="[yellow]Category path - xx/yy/zz..[/yellow]", + callback=category_callback, + ), + ], +): + resp = category_api.move_category(path, new_parent_path) + if not resp.is_success: + center_print(resp.error_message, DisplayBoxType.ERROR) + raise typer.Exit() + + resp = category_api.get_all_categories() + if not resp.is_success: + center_print(resp.error_message, DisplayBoxType.ERROR) + raise typer.Exit() + + center_print("Event Categories", DisplayBoxType.TITLE) + root_categories: list[dict] = resp.body["rootCategories"] + new_path = "/".join(new_parent_path.split("/") + [path.split("/")[-1]]) + display_categories_tree(root_categories, new_path) + + +@app.command( + "colors", + help="[yellow]Show[/yellow] category color palette", + rich_help_panel=":file_folder: [bold yellow1]Category Commands[/bold yellow1]", +) +def show_category_color_palette(): + display_category_color_palette() diff --git a/girok/commands/category/util.py b/girok/commands/category/util.py index 5c427d3..5718d76 100644 --- a/girok/commands/category/util.py +++ b/girok/commands/category/util.py @@ -7,13 +7,14 @@ from rich.text import Text from rich.tree import Tree from girok.constants import ( + CATEGORY_COLOR_AUTO_ASSIGNMENT_ORDER, CATEGORY_COLOR_PALETTE, + CONFIG_PATH, DEFAULT_CATEGORY_TEXT_COLOR, HIGHLIGHT_CATEGORY_TEXT_COLOR, DisplayBoxType, Emoji, ) -from girok.constants import CONFIG_PATH, CATEGORY_COLOR_AUTO_ASSIGNMENT_ORDER from girok.utils.json_utils import read_json, update_json console = Console() @@ -80,6 +81,16 @@ def display_category_subtree( ) +def display_category_color_palette() -> None: + tree = Tree("Supported category colors") + for color_name, hex in CATEGORY_COLOR_PALETTE.items(): + circle_text = Text(text=Emoji.CIRCLE, style=Style(color=CATEGORY_COLOR_PALETTE[color_name])) + category_name_text = Text(text=f"{color_name} ({hex})", style=Style(color=DEFAULT_CATEGORY_TEXT_COLOR)) + item_text = Text.assemble(circle_text, " ", category_name_text) + tree.add(item_text) + console.print(tree) + + def get_next_category_color() -> str: cfg = read_json(CONFIG_PATH) @@ -87,8 +98,8 @@ def get_next_category_color() -> str: next_category_color_idx = cfg["next_category_color_idx"] else: next_category_color_idx = 0 - + next_category_color = CATEGORY_COLOR_AUTO_ASSIGNMENT_ORDER[next_category_color_idx] next_category_color_idx = (next_category_color_idx + 1) % len(CATEGORY_COLOR_AUTO_ASSIGNMENT_ORDER) update_json(CONFIG_PATH, {"next_category_color_idx": next_category_color_idx}) - return next_category_color \ No newline at end of file + return next_category_color diff --git a/girok/config/auth_handler.py b/girok/config/auth_handler.py index 633f702..c70b779 100644 --- a/girok/config/auth_handler.py +++ b/girok/config/auth_handler.py @@ -6,7 +6,7 @@ from girok.utils.json_utils import read_json, update_json, write_json class AuthHandler: - + @classmethod def init(cls) -> None: # Ensure application directory exists diff --git a/girok/constants.py b/girok/constants.py index c316169..710b798 100644 --- a/girok/constants.py +++ b/girok/constants.py @@ -23,6 +23,9 @@ class CommandName: LOGIN = "login" LOGOUT = "logout" + # Category Commands + COLORS = "colors" + # Terminal display color class DisplayBoxType(Enum): @@ -80,7 +83,7 @@ CATEGORY_COLOR_AUTO_ASSIGNMENT_ORDER = [ "BEIGE", "CLOUDY", "CORN", - "LIGHT_PINK" + "LIGHT_PINK", ] DEFAULT_CATEGORY_TEXT_COLOR = "#D7C8B7" diff --git a/girok/girok.py b/girok/girok.py index ef6d4e5..e5296c9 100644 --- a/girok/girok.py +++ b/girok/girok.py @@ -31,7 +31,7 @@ def pre_command_callback(ctx: typer.Context): AuthHandler.init() # Utility commands - if cmd in [CommandName.VERSION]: + if cmd in [CommandName.VERSION, CommandName.COLORS]: return """ diff --git a/girok/utils/display.py b/girok/utils/display.py index 7f77c14..b9aebce 100644 --- a/girok/utils/display.py +++ b/girok/utils/display.py @@ -6,7 +6,7 @@ from rich.console import Console from rich.style import Style from rich.text import Text -from girok.constants import DisplayBoxType, DisplayArrowType +from girok.constants import DisplayArrowType, DisplayBoxType console = Console() @@ -21,4 +21,4 @@ def center_print(text: str, text_type: DisplayBoxType, wrap: bool = False) -> No def arrow_print(text: str, text_type: DisplayArrowType) -> None: - print(f"[{text_type.value}]> {text}[/{text_type.value}]") \ No newline at end of file + print(f"[{text_type.value}]> {text}[/{text_type.value}]") -- cgit v1.2.3