add new tasks
This commit is contained in:
@@ -22,7 +22,11 @@ async def delete_current(app):
|
||||
next_id, next_idx = app.message_store.find_prev_valid_id(current_index)
|
||||
|
||||
# Delete the message using our Himalaya client module
|
||||
message, success = await himalaya_client.delete_message(current_message_id)
|
||||
folder = app.folder if app.folder else None
|
||||
account = app.current_account if app.current_account else None
|
||||
message, success = await himalaya_client.delete_message(
|
||||
current_message_id, folder=folder, account=account
|
||||
)
|
||||
|
||||
if success:
|
||||
app.show_status(f"Message {current_message_id} deleted.", "success")
|
||||
|
||||
103
src/mail/app.py
103
src/mail/app.py
@@ -59,6 +59,7 @@ class EmailViewerApp(App):
|
||||
current_message_index: Reactive[int] = reactive(0)
|
||||
highlighted_message_index: Reactive[int] = reactive(0)
|
||||
folder = reactive("INBOX")
|
||||
current_account: Reactive[str] = reactive("") # Empty string = default account
|
||||
header_expanded = reactive(False)
|
||||
reload_needed = reactive(True)
|
||||
message_store = MessageStore()
|
||||
@@ -233,7 +234,9 @@ class EmailViewerApp(App):
|
||||
async def load_message_content(self, message_id: int) -> None:
|
||||
"""Worker to load message content asynchronously."""
|
||||
content_container = self.query_one(ContentContainer)
|
||||
content_container.display_content(message_id)
|
||||
folder = self.folder if self.folder else None
|
||||
account = self.current_account if self.current_account else None
|
||||
content_container.display_content(message_id, folder=folder, account=account)
|
||||
|
||||
metadata = self.message_store.get_metadata(message_id)
|
||||
if metadata:
|
||||
@@ -259,8 +262,12 @@ class EmailViewerApp(App):
|
||||
if "Seen" in flags:
|
||||
return # Already read
|
||||
|
||||
# Mark as read via himalaya
|
||||
_, success = await himalaya_client.mark_as_read(message_id)
|
||||
# Mark as read via himalaya with current folder/account
|
||||
folder = self.folder if self.folder else None
|
||||
account = self.current_account if self.current_account else None
|
||||
_, success = await himalaya_client.mark_as_read(
|
||||
message_id, folder=folder, account=account
|
||||
)
|
||||
|
||||
if success:
|
||||
# Update the envelope flags in the store
|
||||
@@ -285,6 +292,16 @@ class EmailViewerApp(App):
|
||||
if event.list_view.index is None:
|
||||
return
|
||||
|
||||
# Handle folder selection
|
||||
if event.list_view.id == "folders_list":
|
||||
self._handle_folder_selected(event)
|
||||
return
|
||||
|
||||
# Handle account selection
|
||||
if event.list_view.id == "accounts_list":
|
||||
self._handle_account_selected(event)
|
||||
return
|
||||
|
||||
# Only handle selection from the envelopes list
|
||||
if event.list_view.id != "envelopes_list":
|
||||
return
|
||||
@@ -307,6 +324,45 @@ class EmailViewerApp(App):
|
||||
# Focus the main content panel after selecting a message
|
||||
self.action_focus_4()
|
||||
|
||||
def _handle_folder_selected(self, event: ListView.Selected) -> None:
|
||||
"""Handle folder selection from the folders list."""
|
||||
try:
|
||||
list_item = event.item
|
||||
label = list_item.query_one(Label)
|
||||
folder_name = str(label.renderable).strip()
|
||||
|
||||
if folder_name and folder_name != self.folder:
|
||||
self.folder = folder_name
|
||||
self.show_status(f"Switching to folder: {folder_name}")
|
||||
# Clear current state and reload
|
||||
self.current_message_id = 0
|
||||
self.current_message_index = 0
|
||||
self.selected_messages.clear()
|
||||
self.reload_needed = True
|
||||
except Exception as e:
|
||||
logging.error(f"Error selecting folder: {e}")
|
||||
|
||||
def _handle_account_selected(self, event: ListView.Selected) -> None:
|
||||
"""Handle account selection from the accounts list."""
|
||||
try:
|
||||
list_item = event.item
|
||||
label = list_item.query_one(Label)
|
||||
account_name = str(label.renderable).strip()
|
||||
|
||||
if account_name and account_name != self.current_account:
|
||||
self.current_account = account_name
|
||||
self.folder = "INBOX" # Reset to INBOX when switching accounts
|
||||
self.show_status(f"Switching to account: {account_name}")
|
||||
# Clear current state and reload
|
||||
self.current_message_id = 0
|
||||
self.current_message_index = 0
|
||||
self.selected_messages.clear()
|
||||
# Refresh folders for new account
|
||||
self.fetch_folders()
|
||||
self.reload_needed = True
|
||||
except Exception as e:
|
||||
logging.error(f"Error selecting account: {e}")
|
||||
|
||||
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
|
||||
"""Called when an item in the list view is highlighted (e.g., via arrow keys)."""
|
||||
if event.list_view.index is None:
|
||||
@@ -349,8 +405,12 @@ class EmailViewerApp(App):
|
||||
try:
|
||||
msglist.loading = True
|
||||
|
||||
# Use the Himalaya client to fetch envelopes
|
||||
envelopes, success = await himalaya_client.list_envelopes()
|
||||
# Use the Himalaya client to fetch envelopes with current folder/account
|
||||
folder = self.folder if self.folder else None
|
||||
account = self.current_account if self.current_account else None
|
||||
envelopes, success = await himalaya_client.list_envelopes(
|
||||
folder=folder, account=account
|
||||
)
|
||||
|
||||
if success:
|
||||
self.reload_needed = False
|
||||
@@ -407,14 +467,19 @@ class EmailViewerApp(App):
|
||||
try:
|
||||
folders_list.loading = True
|
||||
|
||||
# Use the Himalaya client to fetch folders
|
||||
folders, success = await himalaya_client.list_folders()
|
||||
# Use the Himalaya client to fetch folders for current account
|
||||
account = self.current_account if self.current_account else None
|
||||
folders, success = await himalaya_client.list_folders(account=account)
|
||||
|
||||
if success and folders:
|
||||
for folder in folders:
|
||||
folder_name = str(folder["name"]).strip()
|
||||
# Skip INBOX since we already added it
|
||||
if folder_name.upper() == "INBOX":
|
||||
continue
|
||||
item = ListItem(
|
||||
Label(
|
||||
str(folder["name"]).strip(),
|
||||
folder_name,
|
||||
classes="folder_name",
|
||||
markup=False,
|
||||
)
|
||||
@@ -560,10 +625,14 @@ class EmailViewerApp(App):
|
||||
)
|
||||
next_id_to_select = next_id
|
||||
|
||||
# Delete each message
|
||||
# Delete each message with current folder/account
|
||||
folder = self.folder if self.folder else None
|
||||
account = self.current_account if self.current_account else None
|
||||
success_count = 0
|
||||
for mid in message_ids_to_delete:
|
||||
message, success = await himalaya_client.delete_message(mid)
|
||||
message, success = await himalaya_client.delete_message(
|
||||
mid, folder=folder, account=account
|
||||
)
|
||||
if success:
|
||||
success_count += 1
|
||||
else:
|
||||
@@ -629,8 +698,13 @@ class EmailViewerApp(App):
|
||||
)
|
||||
next_id_to_select = next_id
|
||||
|
||||
# Archive messages with current folder/account
|
||||
folder = self.folder if self.folder else None
|
||||
account = self.current_account if self.current_account else None
|
||||
message, success = await himalaya_client.archive_messages(
|
||||
[str(mid) for mid in message_ids_to_archive]
|
||||
[str(mid) for mid in message_ids_to_archive],
|
||||
folder=folder,
|
||||
account=account,
|
||||
)
|
||||
|
||||
if success:
|
||||
@@ -671,7 +745,12 @@ class EmailViewerApp(App):
|
||||
next_id, _ = self.message_store.find_prev_valid_id(current_idx)
|
||||
next_id_to_select = next_id
|
||||
|
||||
message, success = await himalaya_client.archive_messages([str(current_id)])
|
||||
# Archive with current folder/account
|
||||
folder = self.folder if self.folder else None
|
||||
account = self.current_account if self.current_account else None
|
||||
message, success = await himalaya_client.archive_messages(
|
||||
[str(current_id)], folder=folder, account=account
|
||||
)
|
||||
|
||||
if success:
|
||||
self.show_status(message or "Archived")
|
||||
|
||||
@@ -80,6 +80,8 @@ class ContentContainer(ScrollableContainer):
|
||||
self.html_content = Static("", id="html_content", markup=False)
|
||||
self.current_content = None
|
||||
self.current_message_id = None
|
||||
self.current_folder: str | None = None
|
||||
self.current_account: str | None = None
|
||||
self.content_worker = None
|
||||
|
||||
# Load default view mode from config
|
||||
@@ -138,18 +140,27 @@ class ContentContainer(ScrollableContainer):
|
||||
self.notify("No message ID provided.")
|
||||
return
|
||||
|
||||
content, success = await himalaya_client.get_message_content(message_id)
|
||||
content, success = await himalaya_client.get_message_content(
|
||||
message_id, folder=self.current_folder, account=self.current_account
|
||||
)
|
||||
if success:
|
||||
self._update_content(content)
|
||||
else:
|
||||
self.notify(f"Failed to fetch content for message ID {message_id}.")
|
||||
|
||||
def display_content(self, message_id: int) -> None:
|
||||
def display_content(
|
||||
self,
|
||||
message_id: int,
|
||||
folder: str | None = None,
|
||||
account: str | None = None,
|
||||
) -> None:
|
||||
"""Display the content of a message."""
|
||||
if not message_id:
|
||||
return
|
||||
|
||||
self.current_message_id = message_id
|
||||
self.current_folder = folder
|
||||
self.current_account = account
|
||||
|
||||
# Immediately show a loading message
|
||||
if self.current_mode == "markdown":
|
||||
|
||||
@@ -343,3 +343,9 @@ class DstaskClient(TaskBackend):
|
||||
# This needs to run without capturing output
|
||||
result = self._run_command(["edit", task_id], capture_output=False)
|
||||
return result.returncode == 0
|
||||
|
||||
def edit_note_interactive(self, task_id: str) -> bool:
|
||||
"""Open task notes in editor for interactive editing."""
|
||||
# This needs to run without capturing output
|
||||
result = self._run_command(["note", task_id], capture_output=False)
|
||||
return result.returncode == 0
|
||||
|
||||
@@ -7,11 +7,17 @@ import subprocess
|
||||
from src.mail.config import get_config
|
||||
|
||||
|
||||
async def list_envelopes(limit: int = 9999) -> Tuple[List[Dict[str, Any]], bool]:
|
||||
async def list_envelopes(
|
||||
folder: Optional[str] = None,
|
||||
account: Optional[str] = None,
|
||||
limit: int = 9999,
|
||||
) -> Tuple[List[Dict[str, Any]], bool]:
|
||||
"""
|
||||
Retrieve a list of email envelopes using the Himalaya CLI.
|
||||
|
||||
Args:
|
||||
folder: The folder to list envelopes from (defaults to INBOX)
|
||||
account: The account to use (defaults to default account)
|
||||
limit: Maximum number of envelopes to retrieve
|
||||
|
||||
Returns:
|
||||
@@ -20,8 +26,14 @@ async def list_envelopes(limit: int = 9999) -> Tuple[List[Dict[str, Any]], bool]
|
||||
- Success status (True if operation was successful)
|
||||
"""
|
||||
try:
|
||||
cmd = f"himalaya envelope list -o json -s {limit}"
|
||||
if folder:
|
||||
cmd += f" -f '{folder}'"
|
||||
if account:
|
||||
cmd += f" -a '{account}'"
|
||||
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
f"himalaya envelope list -o json -s {limit}",
|
||||
cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
@@ -66,18 +78,27 @@ async def list_accounts() -> Tuple[List[Dict[str, Any]], bool]:
|
||||
return [], False
|
||||
|
||||
|
||||
async def list_folders() -> Tuple[List[Dict[str, Any]], bool]:
|
||||
async def list_folders(
|
||||
account: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], bool]:
|
||||
"""
|
||||
Retrieve a list of folders available in Himalaya.
|
||||
|
||||
Args:
|
||||
account: The account to list folders for (defaults to default account)
|
||||
|
||||
Returns:
|
||||
Tuple containing:
|
||||
- List of folder dictionaries
|
||||
- Success status (True if operation was successful)
|
||||
"""
|
||||
try:
|
||||
cmd = "himalaya folder list -o json"
|
||||
if account:
|
||||
cmd += f" -a '{account}'"
|
||||
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
"himalaya folder list -o json",
|
||||
cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
@@ -94,12 +115,18 @@ async def list_folders() -> Tuple[List[Dict[str, Any]], bool]:
|
||||
return [], False
|
||||
|
||||
|
||||
async def delete_message(message_id: int) -> Tuple[Optional[str], bool]:
|
||||
async def delete_message(
|
||||
message_id: int,
|
||||
folder: Optional[str] = None,
|
||||
account: Optional[str] = None,
|
||||
) -> Tuple[Optional[str], bool]:
|
||||
"""
|
||||
Delete a message by its ID.
|
||||
|
||||
Args:
|
||||
message_id: The ID of the message to delete
|
||||
folder: The folder containing the message
|
||||
account: The account to use
|
||||
|
||||
Returns:
|
||||
Tuple containing:
|
||||
@@ -107,8 +134,14 @@ async def delete_message(message_id: int) -> Tuple[Optional[str], bool]:
|
||||
- Success status (True if deletion was successful)
|
||||
"""
|
||||
try:
|
||||
cmd = f"himalaya message delete {message_id}"
|
||||
if folder:
|
||||
cmd += f" -f '{folder}'"
|
||||
if account:
|
||||
cmd += f" -a '{account}'"
|
||||
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
f"himalaya message delete {message_id}",
|
||||
cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
@@ -149,12 +182,18 @@ async def delete_message(message_id: int) -> Tuple[Optional[str], bool]:
|
||||
# return False
|
||||
|
||||
|
||||
async def archive_messages(message_ids: List[str]) -> Tuple[Optional[str], bool]:
|
||||
async def archive_messages(
|
||||
message_ids: List[str],
|
||||
folder: Optional[str] = None,
|
||||
account: Optional[str] = None,
|
||||
) -> Tuple[Optional[str], bool]:
|
||||
"""
|
||||
Archive multiple messages by their IDs.
|
||||
|
||||
Args:
|
||||
message_ids: A list of message IDs to archive.
|
||||
folder: The source folder containing the messages
|
||||
account: The account to use
|
||||
|
||||
Returns:
|
||||
A tuple containing an optional output string and a boolean indicating success.
|
||||
@@ -164,6 +203,10 @@ async def archive_messages(message_ids: List[str]) -> Tuple[Optional[str], bool]
|
||||
archive_folder = config.mail.archive_folder
|
||||
ids_str = " ".join(message_ids)
|
||||
cmd = f"himalaya message move {archive_folder} {ids_str}"
|
||||
if folder:
|
||||
cmd += f" -f '{folder}'"
|
||||
if account:
|
||||
cmd += f" -a '{account}'"
|
||||
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
cmd,
|
||||
@@ -183,12 +226,18 @@ async def archive_messages(message_ids: List[str]) -> Tuple[Optional[str], bool]
|
||||
return str(e), False
|
||||
|
||||
|
||||
async def get_message_content(message_id: int) -> Tuple[Optional[str], bool]:
|
||||
async def get_message_content(
|
||||
message_id: int,
|
||||
folder: Optional[str] = None,
|
||||
account: Optional[str] = None,
|
||||
) -> Tuple[Optional[str], bool]:
|
||||
"""
|
||||
Retrieve the content of a message by its ID.
|
||||
|
||||
Args:
|
||||
message_id: The ID of the message to retrieve
|
||||
folder: The folder containing the message
|
||||
account: The account to use
|
||||
|
||||
Returns:
|
||||
Tuple containing:
|
||||
@@ -197,6 +246,10 @@ async def get_message_content(message_id: int) -> Tuple[Optional[str], bool]:
|
||||
"""
|
||||
try:
|
||||
cmd = f"himalaya message read {message_id}"
|
||||
if folder:
|
||||
cmd += f" -f '{folder}'"
|
||||
if account:
|
||||
cmd += f" -a '{account}'"
|
||||
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
cmd,
|
||||
@@ -216,12 +269,18 @@ async def get_message_content(message_id: int) -> Tuple[Optional[str], bool]:
|
||||
return None, False
|
||||
|
||||
|
||||
async def mark_as_read(message_id: int) -> Tuple[Optional[str], bool]:
|
||||
async def mark_as_read(
|
||||
message_id: int,
|
||||
folder: Optional[str] = None,
|
||||
account: Optional[str] = None,
|
||||
) -> Tuple[Optional[str], bool]:
|
||||
"""
|
||||
Mark a message as read by adding the 'seen' flag.
|
||||
|
||||
Args:
|
||||
message_id: The ID of the message to mark as read
|
||||
folder: The folder containing the message
|
||||
account: The account to use
|
||||
|
||||
Returns:
|
||||
Tuple containing:
|
||||
@@ -230,6 +289,10 @@ async def mark_as_read(message_id: int) -> Tuple[Optional[str], bool]:
|
||||
"""
|
||||
try:
|
||||
cmd = f"himalaya flag add seen {message_id}"
|
||||
if folder:
|
||||
cmd += f" -f '{folder}'"
|
||||
if account:
|
||||
cmd += f" -a '{account}'"
|
||||
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
cmd,
|
||||
|
||||
144
src/tasks/app.py
144
src/tasks/app.py
@@ -10,8 +10,9 @@ from typing import Optional
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import ScrollableContainer
|
||||
from textual.logging import TextualHandler
|
||||
from textual.widgets import DataTable, Footer, Header, Static
|
||||
from textual.widgets import DataTable, Footer, Header, Static, Markdown
|
||||
|
||||
from .config import get_config, TasksAppConfig
|
||||
from .backend import Task, TaskBackend, TaskPriority, TaskStatus, Project
|
||||
@@ -90,6 +91,23 @@ class TasksApp(App):
|
||||
color: $text-muted;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
#notes-pane {
|
||||
dock: bottom;
|
||||
height: 50%;
|
||||
border-top: solid $primary;
|
||||
padding: 1;
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
#notes-pane.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#notes-content {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
@@ -103,6 +121,8 @@ class TasksApp(App):
|
||||
Binding("S", "stop_task", "Stop", show=False),
|
||||
Binding("a", "add_task", "Add", show=True),
|
||||
Binding("e", "edit_task", "Edit", show=True),
|
||||
Binding("n", "toggle_notes", "Notes", show=True),
|
||||
Binding("N", "edit_notes", "Edit Notes", show=False),
|
||||
Binding("x", "delete_task", "Delete", show=False),
|
||||
Binding("p", "filter_project", "Project", show=True),
|
||||
Binding("t", "filter_tag", "Tag", show=True),
|
||||
@@ -122,6 +142,7 @@ class TasksApp(App):
|
||||
current_tag_filters: list[str]
|
||||
current_sort_column: str
|
||||
current_sort_ascending: bool
|
||||
notes_visible: bool
|
||||
backend: Optional[TaskBackend]
|
||||
config: Optional[TasksAppConfig]
|
||||
|
||||
@@ -135,6 +156,7 @@ class TasksApp(App):
|
||||
self.current_tag_filters = []
|
||||
self.current_sort_column = "priority"
|
||||
self.current_sort_ascending = True
|
||||
self.notes_visible = False
|
||||
self.config = get_config()
|
||||
|
||||
if backend:
|
||||
@@ -149,6 +171,11 @@ class TasksApp(App):
|
||||
"""Create the app layout."""
|
||||
yield Header()
|
||||
yield DataTable(id="task-table", cursor_type="row")
|
||||
yield ScrollableContainer(
|
||||
Markdown("*No task selected*", id="notes-content"),
|
||||
id="notes-pane",
|
||||
classes="hidden",
|
||||
)
|
||||
yield TasksStatusBar(id="status-bar")
|
||||
yield Footer()
|
||||
|
||||
@@ -171,6 +198,14 @@ class TasksApp(App):
|
||||
width = w
|
||||
table.add_column(col.capitalize(), width=width, key=col)
|
||||
|
||||
# Set notes pane height from config
|
||||
if self.config:
|
||||
notes_pane = self.query_one("#notes-pane")
|
||||
height = self.config.display.notes_pane_height
|
||||
# Clamp to valid range
|
||||
height = max(10, min(90, height))
|
||||
notes_pane.styles.height = f"{height}%"
|
||||
|
||||
# Load tasks
|
||||
self.load_tasks()
|
||||
|
||||
@@ -395,8 +430,36 @@ class TasksApp(App):
|
||||
|
||||
def action_add_task(self) -> None:
|
||||
"""Add a new task."""
|
||||
# TODO: Push AddTask screen
|
||||
self.notify("Add task not yet implemented", severity="warning")
|
||||
from .screens.AddTaskScreen import AddTaskScreen
|
||||
from .widgets.AddTaskForm import TaskFormData
|
||||
|
||||
# Get project names for dropdown
|
||||
project_names = [p.name for p in self.projects if p.name]
|
||||
|
||||
def handle_task_created(data: TaskFormData | None) -> None:
|
||||
if data is None or not self.backend:
|
||||
return
|
||||
|
||||
try:
|
||||
task = self.backend.add_task(
|
||||
summary=data.summary,
|
||||
project=data.project,
|
||||
tags=data.tags,
|
||||
priority=data.priority,
|
||||
due=data.due,
|
||||
notes=data.notes,
|
||||
)
|
||||
self.notify(
|
||||
f"Task created: {task.summary[:40]}...", severity="information"
|
||||
)
|
||||
self.load_tasks()
|
||||
except Exception as e:
|
||||
self.notify(f"Failed to create task: {e}", severity="error")
|
||||
|
||||
self.push_screen(
|
||||
AddTaskScreen(projects=project_names),
|
||||
handle_task_created,
|
||||
)
|
||||
|
||||
def action_view_task(self) -> None:
|
||||
"""View task details."""
|
||||
@@ -505,6 +568,8 @@ Keybindings:
|
||||
s/S - Start/Stop task
|
||||
a - Add new task
|
||||
e - Edit task in editor
|
||||
n - Toggle notes pane
|
||||
N - Edit notes
|
||||
x - Delete task
|
||||
p - Filter by project
|
||||
t - Filter by tag
|
||||
@@ -517,6 +582,79 @@ Keybindings:
|
||||
"""
|
||||
self.notify(help_text.strip(), timeout=10)
|
||||
|
||||
# Notes actions
|
||||
def action_toggle_notes(self) -> None:
|
||||
"""Toggle the notes pane visibility."""
|
||||
notes_pane = self.query_one("#notes-pane")
|
||||
self.notes_visible = not self.notes_visible
|
||||
|
||||
if self.notes_visible:
|
||||
notes_pane.remove_class("hidden")
|
||||
self._update_notes_display()
|
||||
else:
|
||||
notes_pane.add_class("hidden")
|
||||
|
||||
def action_edit_notes(self) -> None:
|
||||
"""Edit notes for selected task."""
|
||||
task = self._get_selected_task()
|
||||
if not task or not self.backend:
|
||||
return
|
||||
|
||||
# Check config for editor mode
|
||||
use_builtin = self.config and self.config.display.notes_editor == "builtin"
|
||||
|
||||
if use_builtin:
|
||||
self._edit_notes_builtin(task)
|
||||
else:
|
||||
self._edit_notes_external(task)
|
||||
|
||||
def _edit_notes_external(self, task: Task) -> None:
|
||||
"""Edit notes using external $EDITOR."""
|
||||
# Suspend the app, open editor, then resume
|
||||
with self.suspend():
|
||||
self.backend.edit_note_interactive(str(task.id))
|
||||
|
||||
# Reload task to get updated notes
|
||||
self.load_tasks()
|
||||
if self.notes_visible:
|
||||
self._update_notes_display()
|
||||
|
||||
def _edit_notes_builtin(self, task: Task) -> None:
|
||||
"""Edit notes using built-in TextArea widget."""
|
||||
from .screens.NotesEditor import NotesEditorScreen
|
||||
|
||||
def handle_notes_save(new_notes: str | None) -> None:
|
||||
if new_notes is not None and self.backend:
|
||||
# Save the notes via backend
|
||||
self.backend.modify_task(str(task.id), notes=new_notes)
|
||||
self.load_tasks()
|
||||
if self.notes_visible:
|
||||
self._update_notes_display()
|
||||
self.notify("Notes saved", severity="information")
|
||||
|
||||
self.push_screen(
|
||||
NotesEditorScreen(task.id, task.summary, task.notes or ""),
|
||||
handle_notes_save,
|
||||
)
|
||||
|
||||
def _update_notes_display(self) -> None:
|
||||
"""Update the notes pane with the selected task's notes."""
|
||||
task = self._get_selected_task()
|
||||
notes_widget = self.query_one("#notes-content", Markdown)
|
||||
|
||||
if task:
|
||||
if task.notes:
|
||||
notes_widget.update(task.notes)
|
||||
else:
|
||||
notes_widget.update("*No notes for this task*")
|
||||
else:
|
||||
notes_widget.update("*No task selected*")
|
||||
|
||||
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
||||
"""Handle row highlight changes to update notes display."""
|
||||
if self.notes_visible:
|
||||
self._update_notes_display()
|
||||
|
||||
|
||||
def run_app(backend: Optional[TaskBackend] = None) -> None:
|
||||
"""Run the Tasks TUI application."""
|
||||
|
||||
@@ -257,3 +257,15 @@ class TaskBackend(ABC):
|
||||
True if successful
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def edit_note_interactive(self, task_id: str) -> bool:
|
||||
"""Open task notes in editor for interactive editing.
|
||||
|
||||
Args:
|
||||
task_id: Task ID or UUID
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -60,6 +60,12 @@ class DisplayConfig(BaseModel):
|
||||
# Sort direction (asc or desc)
|
||||
sort_direction: Literal["asc", "desc"] = "asc"
|
||||
|
||||
# Notes pane height as percentage (10-90)
|
||||
notes_pane_height: int = 50
|
||||
|
||||
# Notes editor mode: "external" uses $EDITOR, "builtin" uses TextArea widget
|
||||
notes_editor: Literal["external", "builtin"] = "external"
|
||||
|
||||
|
||||
class IconsConfig(BaseModel):
|
||||
"""NerdFont icons for task display."""
|
||||
|
||||
151
src/tasks/screens/AddTaskScreen.py
Normal file
151
src/tasks/screens/AddTaskScreen.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Add Task modal screen for Tasks TUI."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from textual import on
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Button, Input, Label
|
||||
|
||||
from src.tasks.widgets.AddTaskForm import AddTaskForm, TaskFormData
|
||||
|
||||
|
||||
class AddTaskScreen(ModalScreen[Optional[TaskFormData]]):
|
||||
"""Modal screen for adding a new task."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "cancel", "Cancel"),
|
||||
Binding("ctrl+s", "submit", "Save"),
|
||||
]
|
||||
|
||||
DEFAULT_CSS = """
|
||||
AddTaskScreen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
AddTaskScreen #add-task-container {
|
||||
width: 80%;
|
||||
height: auto;
|
||||
max-height: 85%;
|
||||
background: $surface;
|
||||
border: thick $primary;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
AddTaskScreen #add-task-title {
|
||||
text-style: bold;
|
||||
width: 100%;
|
||||
height: 1;
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
AddTaskScreen #add-task-content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
AddTaskScreen #add-task-form {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
AddTaskScreen #add-task-sidebar {
|
||||
width: 16;
|
||||
height: auto;
|
||||
padding: 1;
|
||||
align: center top;
|
||||
}
|
||||
|
||||
AddTaskScreen #add-task-sidebar Button {
|
||||
width: 100%;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
AddTaskScreen #help-text {
|
||||
width: 100%;
|
||||
height: 1;
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
margin-top: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
projects: list[str] | None = None,
|
||||
initial_data: TaskFormData | None = None,
|
||||
mail_link: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the add task screen.
|
||||
|
||||
Args:
|
||||
projects: List of available project names for the dropdown
|
||||
initial_data: Pre-populate form with this data
|
||||
mail_link: Optional mail link to prepend to notes
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._projects = projects or []
|
||||
self._initial_data = initial_data
|
||||
self._mail_link = mail_link
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="add-task-container"):
|
||||
yield Label("Add New Task", id="add-task-title")
|
||||
|
||||
with Horizontal(id="add-task-content"):
|
||||
yield AddTaskForm(
|
||||
projects=self._projects,
|
||||
initial_data=self._initial_data,
|
||||
show_notes=True,
|
||||
mail_link=self._mail_link,
|
||||
id="add-task-form",
|
||||
)
|
||||
|
||||
with Vertical(id="add-task-sidebar"):
|
||||
yield Button("Create", id="create", variant="primary")
|
||||
yield Button("Cancel", id="cancel", variant="default")
|
||||
|
||||
yield Label("Ctrl+S to save, Escape to cancel", id="help-text")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Focus the summary input."""
|
||||
try:
|
||||
form = self.query_one("#add-task-form", AddTaskForm)
|
||||
summary_input = form.query_one("#summary-input")
|
||||
summary_input.focus()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@on(Button.Pressed, "#create")
|
||||
def handle_create(self) -> None:
|
||||
"""Handle create button press."""
|
||||
self.action_submit()
|
||||
|
||||
@on(Button.Pressed, "#cancel")
|
||||
def handle_cancel(self) -> None:
|
||||
"""Handle cancel button press."""
|
||||
self.action_cancel()
|
||||
|
||||
@on(Input.Submitted, "#summary-input")
|
||||
def handle_summary_submit(self) -> None:
|
||||
"""Handle Enter key in summary input."""
|
||||
self.action_submit()
|
||||
|
||||
def action_submit(self) -> None:
|
||||
"""Validate and submit the form."""
|
||||
form = self.query_one("#add-task-form", AddTaskForm)
|
||||
is_valid, error = form.validate()
|
||||
|
||||
if not is_valid:
|
||||
self.notify(error, severity="error")
|
||||
return
|
||||
|
||||
data = form.get_form_data()
|
||||
self.dismiss(data)
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
"""Cancel and dismiss."""
|
||||
self.dismiss(None)
|
||||
131
src/tasks/screens/NotesEditor.py
Normal file
131
src/tasks/screens/NotesEditor.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Notes editor screen using built-in TextArea widget."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Button, Label, TextArea
|
||||
|
||||
|
||||
class NotesEditorScreen(ModalScreen[Optional[str]]):
|
||||
"""Modal screen for editing task notes with built-in TextArea."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "cancel", "Cancel"),
|
||||
Binding("ctrl+s", "save", "Save"),
|
||||
]
|
||||
|
||||
DEFAULT_CSS = """
|
||||
NotesEditorScreen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
NotesEditorScreen #editor-container {
|
||||
width: 80%;
|
||||
height: 80%;
|
||||
background: $surface;
|
||||
border: thick $primary;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
NotesEditorScreen #editor-title {
|
||||
text-style: bold;
|
||||
width: 100%;
|
||||
height: 1;
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
NotesEditorScreen #task-summary {
|
||||
width: 100%;
|
||||
height: 1;
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
NotesEditorScreen TextArea {
|
||||
height: 1fr;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
NotesEditorScreen #editor-buttons {
|
||||
width: 100%;
|
||||
height: 3;
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
NotesEditorScreen Button {
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
NotesEditorScreen #help-text {
|
||||
width: 100%;
|
||||
height: 1;
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
margin-top: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
task_id: int,
|
||||
task_summary: str,
|
||||
current_notes: str,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the notes editor screen.
|
||||
|
||||
Args:
|
||||
task_id: The task ID
|
||||
task_summary: Task summary for display
|
||||
current_notes: Current notes content
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._task_id = task_id
|
||||
self._task_summary = task_summary
|
||||
self._current_notes = current_notes
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="editor-container"):
|
||||
yield Label("Edit Notes", id="editor-title")
|
||||
yield Label(
|
||||
f"Task #{self._task_id}: {self._task_summary[:50]}{'...' if len(self._task_summary) > 50 else ''}",
|
||||
id="task-summary",
|
||||
)
|
||||
|
||||
yield TextArea(
|
||||
self._current_notes,
|
||||
id="notes-textarea",
|
||||
language="markdown",
|
||||
show_line_numbers=True,
|
||||
)
|
||||
|
||||
with Horizontal(id="editor-buttons"):
|
||||
yield Button("Cancel", id="cancel", variant="default")
|
||||
yield Button("Save", id="save", variant="primary")
|
||||
|
||||
yield Label("Ctrl+S to save, Escape to cancel", id="help-text")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Focus the text area."""
|
||||
self.query_one("#notes-textarea", TextArea).focus()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button presses."""
|
||||
if event.button.id == "save":
|
||||
self.action_save()
|
||||
elif event.button.id == "cancel":
|
||||
self.action_cancel()
|
||||
|
||||
def action_save(self) -> None:
|
||||
"""Save the notes and dismiss."""
|
||||
textarea = self.query_one("#notes-textarea", TextArea)
|
||||
self.dismiss(textarea.text)
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
"""Cancel editing and dismiss."""
|
||||
self.dismiss(None)
|
||||
@@ -1,5 +1,14 @@
|
||||
"""Screen components for Tasks TUI."""
|
||||
|
||||
from .FilterScreens import ProjectFilterScreen, TagFilterScreen
|
||||
from .AddTaskScreen import AddTaskScreen
|
||||
from .FilterScreens import ProjectFilterScreen, SortConfig, SortScreen, TagFilterScreen
|
||||
from .NotesEditor import NotesEditorScreen
|
||||
|
||||
__all__ = ["ProjectFilterScreen", "TagFilterScreen"]
|
||||
__all__ = [
|
||||
"AddTaskScreen",
|
||||
"NotesEditorScreen",
|
||||
"ProjectFilterScreen",
|
||||
"SortConfig",
|
||||
"SortScreen",
|
||||
"TagFilterScreen",
|
||||
]
|
||||
|
||||
306
src/tasks/widgets/AddTaskForm.py
Normal file
306
src/tasks/widgets/AddTaskForm.py
Normal file
@@ -0,0 +1,306 @@
|
||||
"""Reusable Add Task form widget for Tasks TUI.
|
||||
|
||||
This widget can be used standalone in modals or embedded in other screens
|
||||
(e.g., the mail app for creating tasks from emails).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.message import Message
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Input, Label, RadioButton, RadioSet, Select, TextArea
|
||||
from textual.widgets._select import NoSelection
|
||||
|
||||
from src.tasks.backend import TaskPriority
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskFormData:
|
||||
"""Data from the add task form."""
|
||||
|
||||
summary: str
|
||||
project: Optional[str] = None
|
||||
tags: list[str] | None = None
|
||||
priority: TaskPriority = TaskPriority.P2
|
||||
due: Optional[datetime] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class AddTaskForm(Widget):
|
||||
"""A reusable form widget for creating/editing tasks.
|
||||
|
||||
This widget emits a TaskFormData when submitted and can be embedded
|
||||
in various contexts (modal screens, sidebars, etc.)
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
AddTaskForm {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
AddTaskForm .form-row {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
AddTaskForm .form-label {
|
||||
width: 12;
|
||||
height: 1;
|
||||
padding-right: 1;
|
||||
}
|
||||
|
||||
AddTaskForm .form-input {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
AddTaskForm #summary-input {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
AddTaskForm #project-select {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
AddTaskForm #tags-input {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
AddTaskForm #due-input {
|
||||
width: 20;
|
||||
}
|
||||
|
||||
AddTaskForm #priority-set {
|
||||
width: 1fr;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
layout: horizontal;
|
||||
}
|
||||
|
||||
AddTaskForm #priority-set RadioButton {
|
||||
width: auto;
|
||||
padding: 0 2;
|
||||
background: transparent;
|
||||
height: 1;
|
||||
}
|
||||
|
||||
AddTaskForm #notes-textarea {
|
||||
width: 1fr;
|
||||
height: 6;
|
||||
}
|
||||
|
||||
AddTaskForm .required {
|
||||
color: $error;
|
||||
}
|
||||
"""
|
||||
|
||||
class Submitted(Message):
|
||||
"""Message emitted when the form is submitted."""
|
||||
|
||||
def __init__(self, data: TaskFormData) -> None:
|
||||
super().__init__()
|
||||
self.data = data
|
||||
|
||||
class Cancelled(Message):
|
||||
"""Message emitted when the form is cancelled."""
|
||||
|
||||
pass
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
projects: list[str] | None = None,
|
||||
initial_data: TaskFormData | None = None,
|
||||
show_notes: bool = True,
|
||||
mail_link: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the add task form.
|
||||
|
||||
Args:
|
||||
projects: List of available project names for the dropdown
|
||||
initial_data: Pre-populate form with this data
|
||||
show_notes: Whether to show the notes field
|
||||
mail_link: Optional mail link to prepend to notes (mail://message-id)
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._projects = projects or []
|
||||
self._initial_data = initial_data
|
||||
self._show_notes = show_notes
|
||||
self._mail_link = mail_link
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the form layout."""
|
||||
initial = self._initial_data or TaskFormData(summary="")
|
||||
|
||||
# Summary (required)
|
||||
with Horizontal(classes="form-row"):
|
||||
yield Label("Summary", classes="form-label")
|
||||
yield Label("*", classes="required")
|
||||
yield Input(
|
||||
value=initial.summary,
|
||||
placeholder="Task summary...",
|
||||
id="summary-input",
|
||||
classes="form-input",
|
||||
)
|
||||
|
||||
# Project (optional dropdown)
|
||||
with Horizontal(classes="form-row"):
|
||||
yield Label("Project", classes="form-label")
|
||||
# Build options list with empty option for "none"
|
||||
options = [("(none)", "")] + [(p, p) for p in self._projects]
|
||||
yield Select(
|
||||
options=options,
|
||||
value=initial.project or "",
|
||||
id="project-select",
|
||||
allow_blank=True,
|
||||
)
|
||||
|
||||
# Tags (comma-separated input)
|
||||
with Horizontal(classes="form-row"):
|
||||
yield Label("Tags", classes="form-label")
|
||||
tags_str = ", ".join(initial.tags) if initial.tags else ""
|
||||
yield Input(
|
||||
value=tags_str,
|
||||
placeholder="tag1, tag2, tag3...",
|
||||
id="tags-input",
|
||||
classes="form-input",
|
||||
)
|
||||
|
||||
# Priority (radio buttons)
|
||||
with Horizontal(classes="form-row"):
|
||||
yield Label("Priority", classes="form-label")
|
||||
with RadioSet(id="priority-set"):
|
||||
yield RadioButton(
|
||||
"P0", value=initial.priority == TaskPriority.P0, id="priority-p0"
|
||||
)
|
||||
yield RadioButton(
|
||||
"P1", value=initial.priority == TaskPriority.P1, id="priority-p1"
|
||||
)
|
||||
yield RadioButton(
|
||||
"P2", value=initial.priority == TaskPriority.P2, id="priority-p2"
|
||||
)
|
||||
yield RadioButton(
|
||||
"P3", value=initial.priority == TaskPriority.P3, id="priority-p3"
|
||||
)
|
||||
|
||||
# Due date (input with date format)
|
||||
with Horizontal(classes="form-row"):
|
||||
yield Label("Due", classes="form-label")
|
||||
due_str = initial.due.strftime("%Y-%m-%d") if initial.due else ""
|
||||
yield Input(
|
||||
value=due_str,
|
||||
placeholder="YYYY-MM-DD",
|
||||
id="due-input",
|
||||
)
|
||||
|
||||
# Notes (optional textarea)
|
||||
if self._show_notes:
|
||||
with Vertical(classes="form-row"):
|
||||
yield Label("Notes", classes="form-label")
|
||||
# If mail_link is provided, prepend it to notes
|
||||
notes_content = initial.notes or ""
|
||||
if self._mail_link:
|
||||
notes_content = f"<!-- {self._mail_link} -->\n\n{notes_content}"
|
||||
yield TextArea(
|
||||
notes_content,
|
||||
id="notes-textarea",
|
||||
language="markdown",
|
||||
)
|
||||
|
||||
def get_form_data(self) -> TaskFormData:
|
||||
"""Extract current form data.
|
||||
|
||||
Returns:
|
||||
TaskFormData with current form values
|
||||
"""
|
||||
summary = self.query_one("#summary-input", Input).value.strip()
|
||||
|
||||
# Get project (handle NoSelection and empty string)
|
||||
project_select = self.query_one("#project-select", Select)
|
||||
project_value = project_select.value
|
||||
project: str | None = None
|
||||
if isinstance(project_value, str) and project_value:
|
||||
project = project_value
|
||||
|
||||
# Get tags (parse comma-separated)
|
||||
tags_str = self.query_one("#tags-input", Input).value.strip()
|
||||
tags = (
|
||||
[t.strip() for t in tags_str.split(",") if t.strip()] if tags_str else None
|
||||
)
|
||||
|
||||
# Get priority from radio set
|
||||
priority_set = self.query_one("#priority-set", RadioSet)
|
||||
priority = TaskPriority.P2 # default
|
||||
if priority_set.pressed_button and priority_set.pressed_button.id:
|
||||
priority_map = {
|
||||
"priority-p0": TaskPriority.P0,
|
||||
"priority-p1": TaskPriority.P1,
|
||||
"priority-p2": TaskPriority.P2,
|
||||
"priority-p3": TaskPriority.P3,
|
||||
}
|
||||
priority = priority_map.get(priority_set.pressed_button.id, TaskPriority.P2)
|
||||
|
||||
# Get due date
|
||||
due_str = self.query_one("#due-input", Input).value.strip()
|
||||
due = None
|
||||
if due_str:
|
||||
try:
|
||||
due = datetime.strptime(due_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
pass # Invalid date format, ignore
|
||||
|
||||
# Get notes
|
||||
notes = None
|
||||
if self._show_notes:
|
||||
try:
|
||||
notes_area = self.query_one("#notes-textarea", TextArea)
|
||||
notes = notes_area.text if notes_area.text.strip() else None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return TaskFormData(
|
||||
summary=summary,
|
||||
project=project,
|
||||
tags=tags,
|
||||
priority=priority,
|
||||
due=due,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
def validate(self) -> tuple[bool, str]:
|
||||
"""Validate the form data.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
data = self.get_form_data()
|
||||
|
||||
if not data.summary:
|
||||
return False, "Summary is required"
|
||||
|
||||
return True, ""
|
||||
|
||||
def submit(self) -> bool:
|
||||
"""Validate and submit the form.
|
||||
|
||||
Returns:
|
||||
True if form was valid and submitted, False otherwise
|
||||
"""
|
||||
is_valid, error = self.validate()
|
||||
if not is_valid:
|
||||
return False
|
||||
|
||||
self.post_message(self.Submitted(self.get_form_data()))
|
||||
return True
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Cancel the form."""
|
||||
self.post_message(self.Cancelled())
|
||||
@@ -1 +1,5 @@
|
||||
"""Widget components for Tasks TUI."""
|
||||
|
||||
from .AddTaskForm import AddTaskForm, TaskFormData
|
||||
|
||||
__all__ = ["AddTaskForm", "TaskFormData"]
|
||||
|
||||
@@ -1,3 +1,35 @@
|
||||
"""
|
||||
Mail utilities module for email operations.
|
||||
"""
|
||||
|
||||
from .helpers import (
|
||||
ensure_directory_exists,
|
||||
format_datetime,
|
||||
format_mail_link,
|
||||
format_mail_link_comment,
|
||||
format_mime_date,
|
||||
has_mail_link,
|
||||
load_last_sync_timestamp,
|
||||
parse_mail_link,
|
||||
parse_maildir_name,
|
||||
remove_mail_link_comment,
|
||||
safe_filename,
|
||||
save_sync_timestamp,
|
||||
truncate_id,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ensure_directory_exists",
|
||||
"format_datetime",
|
||||
"format_mail_link",
|
||||
"format_mail_link_comment",
|
||||
"format_mime_date",
|
||||
"has_mail_link",
|
||||
"load_last_sync_timestamp",
|
||||
"parse_mail_link",
|
||||
"parse_maildir_name",
|
||||
"remove_mail_link_comment",
|
||||
"safe_filename",
|
||||
"save_sync_timestamp",
|
||||
"truncate_id",
|
||||
]
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""
|
||||
Mail utility helper functions.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
import email.utils
|
||||
|
||||
|
||||
def truncate_id(message_id, length=8):
|
||||
"""
|
||||
Truncate a message ID to a reasonable length for display.
|
||||
@@ -24,6 +26,7 @@ def truncate_id(message_id, length=8):
|
||||
return message_id
|
||||
return f"{message_id[:length]}..."
|
||||
|
||||
|
||||
def load_last_sync_timestamp():
|
||||
"""
|
||||
Load the last synchronization timestamp from a file.
|
||||
@@ -32,12 +35,13 @@ def load_last_sync_timestamp():
|
||||
float: The timestamp of the last synchronization, or 0 if not available.
|
||||
"""
|
||||
try:
|
||||
with open('sync_timestamp.json', 'r') as f:
|
||||
with open("sync_timestamp.json", "r") as f:
|
||||
data = json.load(f)
|
||||
return data.get('timestamp', 0)
|
||||
return data.get("timestamp", 0)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return 0
|
||||
|
||||
|
||||
def save_sync_timestamp():
|
||||
"""
|
||||
Save the current timestamp as the last synchronization timestamp.
|
||||
@@ -46,8 +50,9 @@ def save_sync_timestamp():
|
||||
None
|
||||
"""
|
||||
current_time = time.time()
|
||||
with open('sync_timestamp.json', 'w') as f:
|
||||
json.dump({'timestamp': current_time}, f)
|
||||
with open("sync_timestamp.json", "w") as f:
|
||||
json.dump({"timestamp": current_time}, f)
|
||||
|
||||
|
||||
def format_datetime(dt_str, format_string="%m/%d %I:%M %p"):
|
||||
"""
|
||||
@@ -63,11 +68,12 @@ def format_datetime(dt_str, format_string="%m/%d %I:%M %p"):
|
||||
if not dt_str:
|
||||
return ""
|
||||
try:
|
||||
dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
|
||||
dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
|
||||
return dt.strftime(format_string)
|
||||
except (ValueError, AttributeError):
|
||||
return dt_str
|
||||
|
||||
|
||||
def format_mime_date(dt_str):
|
||||
"""
|
||||
Format a datetime string from ISO format to RFC 5322 format for MIME Date headers.
|
||||
@@ -81,11 +87,12 @@ def format_mime_date(dt_str):
|
||||
if not dt_str:
|
||||
return ""
|
||||
try:
|
||||
dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
|
||||
dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
|
||||
return email.utils.format_datetime(dt)
|
||||
except (ValueError, AttributeError):
|
||||
return dt_str
|
||||
|
||||
|
||||
def safe_filename(filename):
|
||||
"""
|
||||
Convert a string to a safe filename.
|
||||
@@ -98,9 +105,10 @@ def safe_filename(filename):
|
||||
"""
|
||||
invalid_chars = '<>:"/\\|?*'
|
||||
for char in invalid_chars:
|
||||
filename = filename.replace(char, '_')
|
||||
filename = filename.replace(char, "_")
|
||||
return filename
|
||||
|
||||
|
||||
def ensure_directory_exists(directory):
|
||||
"""
|
||||
Ensure that a directory exists, creating it if necessary.
|
||||
@@ -114,6 +122,7 @@ def ensure_directory_exists(directory):
|
||||
if not os.path.exists(directory):
|
||||
os.makedirs(directory)
|
||||
|
||||
|
||||
def parse_maildir_name(filename):
|
||||
"""
|
||||
Parse a Maildir filename to extract components.
|
||||
@@ -125,9 +134,104 @@ def parse_maildir_name(filename):
|
||||
tuple: (message_id, flags) components of the filename.
|
||||
"""
|
||||
# Maildir filename format: unique-id:flags
|
||||
if ':' in filename:
|
||||
message_id, flags = filename.split(':', 1)
|
||||
if ":" in filename:
|
||||
message_id, flags = filename.split(":", 1)
|
||||
else:
|
||||
message_id = filename
|
||||
flags = ''
|
||||
flags = ""
|
||||
return message_id, flags
|
||||
|
||||
|
||||
# Mail-Task Link Utilities
|
||||
# These functions handle the mail://message-id links that connect tasks to emails
|
||||
|
||||
MAIL_LINK_PREFIX = "mail://"
|
||||
MAIL_LINK_COMMENT_PATTERN = r"<!--\s*mail://([^>\s]+)\s*-->"
|
||||
|
||||
|
||||
def format_mail_link(message_id: str) -> str:
|
||||
"""
|
||||
Format a message ID as a mail link URI.
|
||||
|
||||
Args:
|
||||
message_id: The email message ID (e.g., "abc123@example.com")
|
||||
|
||||
Returns:
|
||||
Mail link URI (e.g., "mail://abc123@example.com")
|
||||
"""
|
||||
# Clean up message ID - remove angle brackets if present
|
||||
message_id = message_id.strip()
|
||||
if message_id.startswith("<"):
|
||||
message_id = message_id[1:]
|
||||
if message_id.endswith(">"):
|
||||
message_id = message_id[:-1]
|
||||
return f"{MAIL_LINK_PREFIX}{message_id}"
|
||||
|
||||
|
||||
def format_mail_link_comment(message_id: str) -> str:
|
||||
"""
|
||||
Format a message ID as an HTML comment for embedding in task notes.
|
||||
|
||||
Args:
|
||||
message_id: The email message ID
|
||||
|
||||
Returns:
|
||||
HTML comment containing the mail link (e.g., "<!-- mail://abc123@example.com -->")
|
||||
"""
|
||||
return f"<!-- {format_mail_link(message_id)} -->"
|
||||
|
||||
|
||||
def parse_mail_link(notes: str) -> str | None:
|
||||
"""
|
||||
Extract a mail link message ID from task notes.
|
||||
|
||||
Looks for an HTML comment in the format: <!-- mail://message-id -->
|
||||
|
||||
Args:
|
||||
notes: The task notes content
|
||||
|
||||
Returns:
|
||||
The message ID if found, None otherwise
|
||||
"""
|
||||
import re
|
||||
|
||||
if not notes:
|
||||
return None
|
||||
|
||||
match = re.search(MAIL_LINK_COMMENT_PATTERN, notes)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def has_mail_link(notes: str) -> bool:
|
||||
"""
|
||||
Check if task notes contain a mail link.
|
||||
|
||||
Args:
|
||||
notes: The task notes content
|
||||
|
||||
Returns:
|
||||
True if a mail link is found, False otherwise
|
||||
"""
|
||||
return parse_mail_link(notes) is not None
|
||||
|
||||
|
||||
def remove_mail_link_comment(notes: str) -> str:
|
||||
"""
|
||||
Remove the mail link comment from task notes.
|
||||
|
||||
Args:
|
||||
notes: The task notes content
|
||||
|
||||
Returns:
|
||||
Notes with the mail link comment removed
|
||||
"""
|
||||
import re
|
||||
|
||||
if not notes:
|
||||
return ""
|
||||
|
||||
# Remove the mail link comment and any trailing newlines
|
||||
cleaned = re.sub(MAIL_LINK_COMMENT_PATTERN + r"\n*", "", notes)
|
||||
return cleaned.strip()
|
||||
|
||||
Reference in New Issue
Block a user