diff --git a/.coverage b/.coverage index 4ffaba4..c1201d5 100644 Binary files a/.coverage and b/.coverage differ diff --git a/src/maildir_gtd/email_viewer.tcss b/src/maildir_gtd/email_viewer.tcss index 8b58f34..a3962c5 100644 --- a/src/maildir_gtd/email_viewer.tcss +++ b/src/maildir_gtd/email_viewer.tcss @@ -241,7 +241,7 @@ GroupHeader .group-header-label { tint: $accent 20%; } -#open_message_container, #create_task_container { +#open_message_container { border: panel $border; dock: right; width: 25%; diff --git a/src/maildir_gtd/screens/CreateTask.py b/src/maildir_gtd/screens/CreateTask.py index 2458a4f..d26a3bc 100644 --- a/src/maildir_gtd/screens/CreateTask.py +++ b/src/maildir_gtd/screens/CreateTask.py @@ -2,13 +2,85 @@ import logging from textual.screen import ModalScreen from textual.widgets import Input, Label, Button, ListView, ListItem from textual.containers import Vertical, Horizontal, Container +from textual.binding import Binding from textual import on, work -from src.services.taskwarrior import client as taskwarrior_client +from src.services.task_client import create_task, get_backend_info class CreateTaskScreen(ModalScreen): """Screen for creating a new task.""" + BINDINGS = [ + Binding("escape", "cancel", "Close"), + Binding("ctrl+s", "submit", "Create Task"), + ] + + DEFAULT_CSS = """ + CreateTaskScreen { + align: right middle; + } + + CreateTaskScreen #create_task_container { + dock: right; + width: 40%; + min-width: 50; + max-width: 80; + height: 100%; + background: $surface; + border: round $primary; + padding: 1 2; + } + + CreateTaskScreen #create_task_container:focus-within { + border: round $accent; + } + + CreateTaskScreen #create_task_form { + height: auto; + width: 1fr; + } + + CreateTaskScreen .form-field { + height: auto; + width: 1fr; + margin-bottom: 1; + } + + CreateTaskScreen .form-field Label { + height: 1; + width: 1fr; + color: $text-muted; + margin-bottom: 0; + } + + CreateTaskScreen .form-field Input { + width: 1fr; + } + + CreateTaskScreen .form-field Input:focus { + border: tall $accent; + } + + CreateTaskScreen .button-row { + height: auto; + width: 1fr; + align: center middle; + margin-top: 1; + } + + CreateTaskScreen .button-row Button { + margin: 0 1; + } + + CreateTaskScreen .form-hint { + height: 1; + width: 1fr; + color: $text-muted; + text-align: center; + margin-top: 1; + } + """ + def __init__(self, subject="", from_addr="", **kwargs): super().__init__(**kwargs) self.subject = subject @@ -16,62 +88,84 @@ class CreateTaskScreen(ModalScreen): self.selected_project = None def compose(self): - yield Container( - Vertical( - Horizontal( - Label("Subject:"), - Input( - placeholder="Task subject", + with Container(id="create_task_container"): + with Vertical(id="create_task_form"): + # Subject field + with Vertical(classes="form-field"): + yield Label("Subject") + yield Input( + placeholder="Task description", value=self.subject, id="subject_input", - ), - ), - Horizontal( - Label("Project:"), - Input(placeholder="Project name", id="project_input"), - ), - Horizontal( - Label("Tags:"), - Input(placeholder="Comma-separated tags", id="tags_input"), - ), - Horizontal( - Label("Due:"), - Input( - placeholder="Due date (e.g., today, tomorrow, fri)", + ) + + # Project field + with Vertical(classes="form-field"): + yield Label("Project") + yield Input(placeholder="e.g., work, home", id="project_input") + + # Tags field + with Vertical(classes="form-field"): + yield Label("Tags") + yield Input(placeholder="tag1, tag2, ...", id="tags_input") + + # Due date field + with Vertical(classes="form-field"): + yield Label("Due") + yield Input( + placeholder="today, tomorrow, fri, 2024-01-15", id="due_input", - ), - ), - Horizontal( - Label("Priority:"), - Input(placeholder="Priority (H, M, L)", id="priority_input"), - ), - Horizontal( - Button("Create", id="create_btn", variant="primary"), - Button("Cancel", id="cancel_btn", variant="error"), - ), - id="create_task_form", - ), - id="create_task_container", - ) + ) + + # Priority field + with Vertical(classes="form-field"): + yield Label("Priority") + yield Input(placeholder="H, M, or L", id="priority_input") + + # Buttons + with Horizontal(classes="button-row"): + yield Button("Create", id="create_btn", variant="primary") + yield Button("Cancel", id="cancel_btn", variant="error") + + yield Label("ctrl+s: create, esc: cancel", classes="form-hint") def on_mount(self): - self.query_one("#create_task_container", - Container).border_title = "New Task (taskwarrior)" - self.styles.align = ("center", "middle") + backend_name, _ = get_backend_info() + container = self.query_one("#create_task_container", Container) + container.border_title = "\uf0ae New Task" # nf-fa-tasks + container.border_subtitle = backend_name + # Focus the subject input + self.query_one("#subject_input", Input).focus() + + def action_cancel(self): + """Close the screen.""" + self.dismiss() + + def action_submit(self): + """Submit the form.""" + self._create_task() + + @on(Input.Submitted) + def on_input_submitted(self, event: Input.Submitted): + """Handle Enter key in any input field.""" + self._create_task() @on(Button.Pressed, "#create_btn") def on_create_pressed(self): """Create the task when the Create button is pressed.""" + self._create_task() + + def _create_task(self): + """Gather form data and create the task.""" # Get input values - subject = self.query_one("#subject_input").value - project = self.query_one("#project_input").value - tags_input = self.query_one("#tags_input").value - due = self.query_one("#due_input").value - priority = self.query_one("#priority_input").value + subject = self.query_one("#subject_input", Input).value + project = self.query_one("#project_input", Input).value + tags_input = self.query_one("#tags_input", Input).value + due = self.query_one("#due_input", Input).value + priority = self.query_one("#priority_input", Input).value # Process tags (split by commas and trim whitespace) - tags = [tag.strip() - for tag in tags_input.split(",")] if tags_input else [] + tags = [tag.strip() for tag in tags_input.split(",")] if tags_input else [] # Add a tag for the sender, if provided if self.from_addr and "@" in self.from_addr: @@ -91,18 +185,20 @@ class CreateTaskScreen(ModalScreen): async def create_task_worker( self, subject, tags=None, project=None, due=None, priority=None ): - """Worker to create a task using the Taskwarrior API client.""" + """Worker to create a task using the configured backend.""" if not subject: self.app.show_status("Task subject cannot be empty.", "error") return # Validate priority - if priority and priority not in ["H", "M", "L"]: + if priority and priority.upper() not in ["H", "M", "L"]: self.app.show_status("Priority must be H, M, or L.", "warning") priority = None + elif priority: + priority = priority.upper() - # Create the task - success, result = await taskwarrior_client.create_task( + # Create the task using the unified client + success, result = await create_task( task_description=subject, tags=tags or [], project=project, diff --git a/src/services/task_client.py b/src/services/task_client.py new file mode 100644 index 0000000..98c6e4c --- /dev/null +++ b/src/services/task_client.py @@ -0,0 +1,305 @@ +"""Unified task client that supports multiple backends (taskwarrior, dstask).""" + +import asyncio +import json +import logging +import shlex +from typing import Tuple, List, Dict, Any, Optional + +from src.maildir_gtd.config import get_config + +logger = logging.getLogger(__name__) + + +def get_backend_info() -> Tuple[str, str]: + """Get the configured backend name and command path. + + Returns: + Tuple of (backend_name, command_path) + """ + config = get_config() + backend = config.task.backend + + if backend == "dstask": + return "dstask", config.task.dstask_path + else: + return "taskwarrior", config.task.taskwarrior_path + + +async def create_task( + task_description: str, + tags: List[str] = None, + project: str = None, + due: str = None, + priority: str = None, +) -> Tuple[bool, Optional[str]]: + """ + Create a new task using the configured backend. + + Args: + task_description: Description of the task + tags: List of tags to apply to the task + project: Project to which the task belongs + due: Due date in the format the backend accepts + priority: Priority of the task (H, M, L) + + Returns: + Tuple containing: + - Success status (True if operation was successful) + - Task ID/message or error message + """ + backend, cmd_path = get_backend_info() + + try: + if backend == "dstask": + return await _create_task_dstask( + cmd_path, task_description, tags, project, due, priority + ) + else: + return await _create_task_taskwarrior( + cmd_path, task_description, tags, project, due, priority + ) + except Exception as e: + logger.error(f"Exception during task creation: {e}") + return False, str(e) + + +async def _create_task_taskwarrior( + cmd_path: str, + task_description: str, + tags: List[str] = None, + project: str = None, + due: str = None, + priority: str = None, +) -> Tuple[bool, Optional[str]]: + """Create task using taskwarrior.""" + cmd = [cmd_path, "add"] + + # Add project if specified + if project: + cmd.append(f"project:{project}") + + # Add tags if specified + if tags: + for tag in tags: + if tag: + cmd.append(f"+{tag}") + + # Add due date if specified + if due: + cmd.append(f"due:{due}") + + # Add priority if specified + if priority and priority in ["H", "M", "L"]: + cmd.append(f"priority:{priority}") + + # Add task description + cmd.append(task_description) + + # Use shlex.join for proper escaping + cmd_str = shlex.join(cmd) + logger.debug(f"Taskwarrior command: {cmd_str}") + + process = await asyncio.create_subprocess_shell( + cmd_str, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + if process.returncode == 0: + return True, stdout.decode().strip() + else: + error_msg = stderr.decode().strip() + logger.error(f"Error creating task: {error_msg}") + return False, error_msg + + +async def _create_task_dstask( + cmd_path: str, + task_description: str, + tags: List[str] = None, + project: str = None, + due: str = None, + priority: str = None, +) -> Tuple[bool, Optional[str]]: + """Create task using dstask. + + dstask syntax: dstask add [+tag...] [project:X] [priority:X] [due:X] description + """ + cmd = [cmd_path, "add"] + + # Add tags if specified (dstask uses +tag syntax like taskwarrior) + if tags: + for tag in tags: + if tag: + cmd.append(f"+{tag}") + + # Add project if specified + if project: + cmd.append(f"project:{project}") + + # Add priority if specified (dstask uses P1, P2, P3 but also accepts priority:H/M/L) + if priority and priority in ["H", "M", "L"]: + # Map to dstask priority format + priority_map = {"H": "P1", "M": "P2", "L": "P3"} + cmd.append(priority_map[priority]) + + # Add due date if specified + if due: + cmd.append(f"due:{due}") + + # Add task description (must be last for dstask) + cmd.append(task_description) + + # Use shlex.join for proper escaping + cmd_str = shlex.join(cmd) + logger.debug(f"dstask command: {cmd_str}") + + process = await asyncio.create_subprocess_shell( + cmd_str, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + if process.returncode == 0: + return True, stdout.decode().strip() + else: + error_msg = stderr.decode().strip() + logger.error(f"Error creating dstask task: {error_msg}") + return False, error_msg + + +async def list_tasks(filter_str: str = "") -> Tuple[List[Dict[str, Any]], bool]: + """ + List tasks from the configured backend. + + Args: + filter_str: Optional filter string + + Returns: + Tuple containing: + - List of task dictionaries + - Success status (True if operation was successful) + """ + backend, cmd_path = get_backend_info() + + try: + if backend == "dstask": + return await _list_tasks_dstask(cmd_path, filter_str) + else: + return await _list_tasks_taskwarrior(cmd_path, filter_str) + except Exception as e: + logger.error(f"Exception during task listing: {e}") + return [], False + + +async def _list_tasks_taskwarrior( + cmd_path: str, filter_str: str = "" +) -> Tuple[List[Dict[str, Any]], bool]: + """List tasks using taskwarrior.""" + cmd = f"{shlex.quote(cmd_path)} {filter_str} export" + + process = await asyncio.create_subprocess_shell( + cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + if process.returncode == 0: + tasks = json.loads(stdout.decode()) + return tasks, True + else: + logger.error(f"Error listing tasks: {stderr.decode()}") + return [], False + + +async def _list_tasks_dstask( + cmd_path: str, filter_str: str = "" +) -> Tuple[List[Dict[str, Any]], bool]: + """List tasks using dstask.""" + # dstask uses 'dstask export' for JSON output + cmd = f"{shlex.quote(cmd_path)} {filter_str} export" + + process = await asyncio.create_subprocess_shell( + cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + if process.returncode == 0: + try: + tasks = json.loads(stdout.decode()) + return tasks, True + except json.JSONDecodeError: + # dstask might output tasks differently + logger.warning("Could not parse dstask JSON output") + return [], False + else: + logger.error(f"Error listing dstask tasks: {stderr.decode()}") + return [], False + + +async def complete_task(task_id: str) -> bool: + """ + Mark a task as completed. + + Args: + task_id: ID of the task to complete + + Returns: + True if task was completed successfully, False otherwise + """ + backend, cmd_path = get_backend_info() + + try: + if backend == "dstask": + cmd = f"{shlex.quote(cmd_path)} done {task_id}" + else: + cmd = f"echo 'yes' | {shlex.quote(cmd_path)} {task_id} done" + + process = await asyncio.create_subprocess_shell( + cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + return process.returncode == 0 + except Exception as e: + logger.error(f"Exception during task completion: {e}") + return False + + +async def delete_task(task_id: str) -> bool: + """ + Delete a task. + + Args: + task_id: ID of the task to delete + + Returns: + True if task was deleted successfully, False otherwise + """ + backend, cmd_path = get_backend_info() + + try: + if backend == "dstask": + cmd = f"{shlex.quote(cmd_path)} remove {task_id}" + else: + cmd = f"echo 'yes' | {shlex.quote(cmd_path)} {task_id} delete" + + process = await asyncio.create_subprocess_shell( + cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + return process.returncode == 0 + except Exception as e: + logger.error(f"Exception during task deletion: {e}") + return False