task creation

This commit is contained in:
Bendt
2025-12-18 12:09:21 -05:00
parent 82fbc31683
commit 4dbb7c5fea
4 changed files with 451 additions and 50 deletions

BIN
.coverage

Binary file not shown.

View File

@@ -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%;

View File

@@ -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)",
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",
)
# 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",
)
# 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,

305
src/services/task_client.py Normal file
View File

@@ -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