task creation
This commit is contained in:
@@ -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%;
|
||||
|
||||
@@ -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,
|
||||
|
||||
305
src/services/task_client.py
Normal file
305
src/services/task_client.py
Normal 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
|
||||
Reference in New Issue
Block a user