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)
|
next_id, next_idx = app.message_store.find_prev_valid_id(current_index)
|
||||||
|
|
||||||
# Delete the message using our Himalaya client module
|
# 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:
|
if success:
|
||||||
app.show_status(f"Message {current_message_id} deleted.", "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)
|
current_message_index: Reactive[int] = reactive(0)
|
||||||
highlighted_message_index: Reactive[int] = reactive(0)
|
highlighted_message_index: Reactive[int] = reactive(0)
|
||||||
folder = reactive("INBOX")
|
folder = reactive("INBOX")
|
||||||
|
current_account: Reactive[str] = reactive("") # Empty string = default account
|
||||||
header_expanded = reactive(False)
|
header_expanded = reactive(False)
|
||||||
reload_needed = reactive(True)
|
reload_needed = reactive(True)
|
||||||
message_store = MessageStore()
|
message_store = MessageStore()
|
||||||
@@ -233,7 +234,9 @@ class EmailViewerApp(App):
|
|||||||
async def load_message_content(self, message_id: int) -> None:
|
async def load_message_content(self, message_id: int) -> None:
|
||||||
"""Worker to load message content asynchronously."""
|
"""Worker to load message content asynchronously."""
|
||||||
content_container = self.query_one(ContentContainer)
|
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)
|
metadata = self.message_store.get_metadata(message_id)
|
||||||
if metadata:
|
if metadata:
|
||||||
@@ -259,8 +262,12 @@ class EmailViewerApp(App):
|
|||||||
if "Seen" in flags:
|
if "Seen" in flags:
|
||||||
return # Already read
|
return # Already read
|
||||||
|
|
||||||
# Mark as read via himalaya
|
# Mark as read via himalaya with current folder/account
|
||||||
_, success = await himalaya_client.mark_as_read(message_id)
|
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:
|
if success:
|
||||||
# Update the envelope flags in the store
|
# Update the envelope flags in the store
|
||||||
@@ -285,6 +292,16 @@ class EmailViewerApp(App):
|
|||||||
if event.list_view.index is None:
|
if event.list_view.index is None:
|
||||||
return
|
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
|
# Only handle selection from the envelopes list
|
||||||
if event.list_view.id != "envelopes_list":
|
if event.list_view.id != "envelopes_list":
|
||||||
return
|
return
|
||||||
@@ -307,6 +324,45 @@ class EmailViewerApp(App):
|
|||||||
# Focus the main content panel after selecting a message
|
# Focus the main content panel after selecting a message
|
||||||
self.action_focus_4()
|
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:
|
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)."""
|
"""Called when an item in the list view is highlighted (e.g., via arrow keys)."""
|
||||||
if event.list_view.index is None:
|
if event.list_view.index is None:
|
||||||
@@ -349,8 +405,12 @@ class EmailViewerApp(App):
|
|||||||
try:
|
try:
|
||||||
msglist.loading = True
|
msglist.loading = True
|
||||||
|
|
||||||
# Use the Himalaya client to fetch envelopes
|
# Use the Himalaya client to fetch envelopes with current folder/account
|
||||||
envelopes, success = await himalaya_client.list_envelopes()
|
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:
|
if success:
|
||||||
self.reload_needed = False
|
self.reload_needed = False
|
||||||
@@ -407,14 +467,19 @@ class EmailViewerApp(App):
|
|||||||
try:
|
try:
|
||||||
folders_list.loading = True
|
folders_list.loading = True
|
||||||
|
|
||||||
# Use the Himalaya client to fetch folders
|
# Use the Himalaya client to fetch folders for current account
|
||||||
folders, success = await himalaya_client.list_folders()
|
account = self.current_account if self.current_account else None
|
||||||
|
folders, success = await himalaya_client.list_folders(account=account)
|
||||||
|
|
||||||
if success and folders:
|
if success and folders:
|
||||||
for folder in 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(
|
item = ListItem(
|
||||||
Label(
|
Label(
|
||||||
str(folder["name"]).strip(),
|
folder_name,
|
||||||
classes="folder_name",
|
classes="folder_name",
|
||||||
markup=False,
|
markup=False,
|
||||||
)
|
)
|
||||||
@@ -560,10 +625,14 @@ class EmailViewerApp(App):
|
|||||||
)
|
)
|
||||||
next_id_to_select = next_id
|
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
|
success_count = 0
|
||||||
for mid in message_ids_to_delete:
|
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:
|
if success:
|
||||||
success_count += 1
|
success_count += 1
|
||||||
else:
|
else:
|
||||||
@@ -629,8 +698,13 @@ class EmailViewerApp(App):
|
|||||||
)
|
)
|
||||||
next_id_to_select = next_id
|
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(
|
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:
|
if success:
|
||||||
@@ -671,7 +745,12 @@ class EmailViewerApp(App):
|
|||||||
next_id, _ = self.message_store.find_prev_valid_id(current_idx)
|
next_id, _ = self.message_store.find_prev_valid_id(current_idx)
|
||||||
next_id_to_select = next_id
|
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:
|
if success:
|
||||||
self.show_status(message or "Archived")
|
self.show_status(message or "Archived")
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ class ContentContainer(ScrollableContainer):
|
|||||||
self.html_content = Static("", id="html_content", markup=False)
|
self.html_content = Static("", id="html_content", markup=False)
|
||||||
self.current_content = None
|
self.current_content = None
|
||||||
self.current_message_id = None
|
self.current_message_id = None
|
||||||
|
self.current_folder: str | None = None
|
||||||
|
self.current_account: str | None = None
|
||||||
self.content_worker = None
|
self.content_worker = None
|
||||||
|
|
||||||
# Load default view mode from config
|
# Load default view mode from config
|
||||||
@@ -138,18 +140,27 @@ class ContentContainer(ScrollableContainer):
|
|||||||
self.notify("No message ID provided.")
|
self.notify("No message ID provided.")
|
||||||
return
|
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:
|
if success:
|
||||||
self._update_content(content)
|
self._update_content(content)
|
||||||
else:
|
else:
|
||||||
self.notify(f"Failed to fetch content for message ID {message_id}.")
|
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."""
|
"""Display the content of a message."""
|
||||||
if not message_id:
|
if not message_id:
|
||||||
return
|
return
|
||||||
|
|
||||||
self.current_message_id = message_id
|
self.current_message_id = message_id
|
||||||
|
self.current_folder = folder
|
||||||
|
self.current_account = account
|
||||||
|
|
||||||
# Immediately show a loading message
|
# Immediately show a loading message
|
||||||
if self.current_mode == "markdown":
|
if self.current_mode == "markdown":
|
||||||
|
|||||||
@@ -343,3 +343,9 @@ class DstaskClient(TaskBackend):
|
|||||||
# This needs to run without capturing output
|
# This needs to run without capturing output
|
||||||
result = self._run_command(["edit", task_id], capture_output=False)
|
result = self._run_command(["edit", task_id], capture_output=False)
|
||||||
return result.returncode == 0
|
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
|
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.
|
Retrieve a list of email envelopes using the Himalaya CLI.
|
||||||
|
|
||||||
Args:
|
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
|
limit: Maximum number of envelopes to retrieve
|
||||||
|
|
||||||
Returns:
|
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)
|
- Success status (True if operation was successful)
|
||||||
"""
|
"""
|
||||||
try:
|
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(
|
process = await asyncio.create_subprocess_shell(
|
||||||
f"himalaya envelope list -o json -s {limit}",
|
cmd,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
@@ -66,18 +78,27 @@ async def list_accounts() -> Tuple[List[Dict[str, Any]], bool]:
|
|||||||
return [], False
|
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.
|
Retrieve a list of folders available in Himalaya.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account: The account to list folders for (defaults to default account)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple containing:
|
Tuple containing:
|
||||||
- List of folder dictionaries
|
- List of folder dictionaries
|
||||||
- Success status (True if operation was successful)
|
- Success status (True if operation was successful)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
cmd = "himalaya folder list -o json"
|
||||||
|
if account:
|
||||||
|
cmd += f" -a '{account}'"
|
||||||
|
|
||||||
process = await asyncio.create_subprocess_shell(
|
process = await asyncio.create_subprocess_shell(
|
||||||
"himalaya folder list -o json",
|
cmd,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
@@ -94,12 +115,18 @@ async def list_folders() -> Tuple[List[Dict[str, Any]], bool]:
|
|||||||
return [], False
|
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.
|
Delete a message by its ID.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
message_id: The ID of the message to delete
|
message_id: The ID of the message to delete
|
||||||
|
folder: The folder containing the message
|
||||||
|
account: The account to use
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple containing:
|
Tuple containing:
|
||||||
@@ -107,8 +134,14 @@ async def delete_message(message_id: int) -> Tuple[Optional[str], bool]:
|
|||||||
- Success status (True if deletion was successful)
|
- Success status (True if deletion was successful)
|
||||||
"""
|
"""
|
||||||
try:
|
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(
|
process = await asyncio.create_subprocess_shell(
|
||||||
f"himalaya message delete {message_id}",
|
cmd,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
@@ -149,12 +182,18 @@ async def delete_message(message_id: int) -> Tuple[Optional[str], bool]:
|
|||||||
# return False
|
# 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.
|
Archive multiple messages by their IDs.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
message_ids: A list of message IDs to archive.
|
message_ids: A list of message IDs to archive.
|
||||||
|
folder: The source folder containing the messages
|
||||||
|
account: The account to use
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A tuple containing an optional output string and a boolean indicating success.
|
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
|
archive_folder = config.mail.archive_folder
|
||||||
ids_str = " ".join(message_ids)
|
ids_str = " ".join(message_ids)
|
||||||
cmd = f"himalaya message move {archive_folder} {ids_str}"
|
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(
|
process = await asyncio.create_subprocess_shell(
|
||||||
cmd,
|
cmd,
|
||||||
@@ -183,12 +226,18 @@ async def archive_messages(message_ids: List[str]) -> Tuple[Optional[str], bool]
|
|||||||
return str(e), False
|
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.
|
Retrieve the content of a message by its ID.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
message_id: The ID of the message to retrieve
|
message_id: The ID of the message to retrieve
|
||||||
|
folder: The folder containing the message
|
||||||
|
account: The account to use
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple containing:
|
Tuple containing:
|
||||||
@@ -197,6 +246,10 @@ async def get_message_content(message_id: int) -> Tuple[Optional[str], bool]:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
cmd = f"himalaya message read {message_id}"
|
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(
|
process = await asyncio.create_subprocess_shell(
|
||||||
cmd,
|
cmd,
|
||||||
@@ -216,12 +269,18 @@ async def get_message_content(message_id: int) -> Tuple[Optional[str], bool]:
|
|||||||
return None, False
|
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.
|
Mark a message as read by adding the 'seen' flag.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
message_id: The ID of the message to mark as read
|
message_id: The ID of the message to mark as read
|
||||||
|
folder: The folder containing the message
|
||||||
|
account: The account to use
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple containing:
|
Tuple containing:
|
||||||
@@ -230,6 +289,10 @@ async def mark_as_read(message_id: int) -> Tuple[Optional[str], bool]:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
cmd = f"himalaya flag add seen {message_id}"
|
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(
|
process = await asyncio.create_subprocess_shell(
|
||||||
cmd,
|
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.app import App, ComposeResult
|
||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
|
from textual.containers import ScrollableContainer
|
||||||
from textual.logging import TextualHandler
|
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 .config import get_config, TasksAppConfig
|
||||||
from .backend import Task, TaskBackend, TaskPriority, TaskStatus, Project
|
from .backend import Task, TaskBackend, TaskPriority, TaskStatus, Project
|
||||||
@@ -90,6 +91,23 @@ class TasksApp(App):
|
|||||||
color: $text-muted;
|
color: $text-muted;
|
||||||
padding: 0 1;
|
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 = [
|
BINDINGS = [
|
||||||
@@ -103,6 +121,8 @@ class TasksApp(App):
|
|||||||
Binding("S", "stop_task", "Stop", show=False),
|
Binding("S", "stop_task", "Stop", show=False),
|
||||||
Binding("a", "add_task", "Add", show=True),
|
Binding("a", "add_task", "Add", show=True),
|
||||||
Binding("e", "edit_task", "Edit", 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("x", "delete_task", "Delete", show=False),
|
||||||
Binding("p", "filter_project", "Project", show=True),
|
Binding("p", "filter_project", "Project", show=True),
|
||||||
Binding("t", "filter_tag", "Tag", show=True),
|
Binding("t", "filter_tag", "Tag", show=True),
|
||||||
@@ -122,6 +142,7 @@ class TasksApp(App):
|
|||||||
current_tag_filters: list[str]
|
current_tag_filters: list[str]
|
||||||
current_sort_column: str
|
current_sort_column: str
|
||||||
current_sort_ascending: bool
|
current_sort_ascending: bool
|
||||||
|
notes_visible: bool
|
||||||
backend: Optional[TaskBackend]
|
backend: Optional[TaskBackend]
|
||||||
config: Optional[TasksAppConfig]
|
config: Optional[TasksAppConfig]
|
||||||
|
|
||||||
@@ -135,6 +156,7 @@ class TasksApp(App):
|
|||||||
self.current_tag_filters = []
|
self.current_tag_filters = []
|
||||||
self.current_sort_column = "priority"
|
self.current_sort_column = "priority"
|
||||||
self.current_sort_ascending = True
|
self.current_sort_ascending = True
|
||||||
|
self.notes_visible = False
|
||||||
self.config = get_config()
|
self.config = get_config()
|
||||||
|
|
||||||
if backend:
|
if backend:
|
||||||
@@ -149,6 +171,11 @@ class TasksApp(App):
|
|||||||
"""Create the app layout."""
|
"""Create the app layout."""
|
||||||
yield Header()
|
yield Header()
|
||||||
yield DataTable(id="task-table", cursor_type="row")
|
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 TasksStatusBar(id="status-bar")
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
@@ -171,6 +198,14 @@ class TasksApp(App):
|
|||||||
width = w
|
width = w
|
||||||
table.add_column(col.capitalize(), width=width, key=col)
|
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
|
# Load tasks
|
||||||
self.load_tasks()
|
self.load_tasks()
|
||||||
|
|
||||||
@@ -395,8 +430,36 @@ class TasksApp(App):
|
|||||||
|
|
||||||
def action_add_task(self) -> None:
|
def action_add_task(self) -> None:
|
||||||
"""Add a new task."""
|
"""Add a new task."""
|
||||||
# TODO: Push AddTask screen
|
from .screens.AddTaskScreen import AddTaskScreen
|
||||||
self.notify("Add task not yet implemented", severity="warning")
|
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:
|
def action_view_task(self) -> None:
|
||||||
"""View task details."""
|
"""View task details."""
|
||||||
@@ -505,6 +568,8 @@ Keybindings:
|
|||||||
s/S - Start/Stop task
|
s/S - Start/Stop task
|
||||||
a - Add new task
|
a - Add new task
|
||||||
e - Edit task in editor
|
e - Edit task in editor
|
||||||
|
n - Toggle notes pane
|
||||||
|
N - Edit notes
|
||||||
x - Delete task
|
x - Delete task
|
||||||
p - Filter by project
|
p - Filter by project
|
||||||
t - Filter by tag
|
t - Filter by tag
|
||||||
@@ -517,6 +582,79 @@ Keybindings:
|
|||||||
"""
|
"""
|
||||||
self.notify(help_text.strip(), timeout=10)
|
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:
|
def run_app(backend: Optional[TaskBackend] = None) -> None:
|
||||||
"""Run the Tasks TUI application."""
|
"""Run the Tasks TUI application."""
|
||||||
|
|||||||
@@ -257,3 +257,15 @@ class TaskBackend(ABC):
|
|||||||
True if successful
|
True if successful
|
||||||
"""
|
"""
|
||||||
pass
|
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 (asc or desc)
|
||||||
sort_direction: Literal["asc", "desc"] = "asc"
|
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):
|
class IconsConfig(BaseModel):
|
||||||
"""NerdFont icons for task display."""
|
"""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."""
|
"""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."""
|
"""Widget components for Tasks TUI."""
|
||||||
|
|
||||||
|
from .AddTaskForm import AddTaskForm, TaskFormData
|
||||||
|
|
||||||
|
__all__ = ["AddTaskForm", "TaskFormData"]
|
||||||
|
|||||||
@@ -1,3 +1,35 @@
|
|||||||
"""
|
"""
|
||||||
Mail utilities module for email operations.
|
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.
|
Mail utility helper functions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import email.utils
|
import email.utils
|
||||||
|
|
||||||
|
|
||||||
def truncate_id(message_id, length=8):
|
def truncate_id(message_id, length=8):
|
||||||
"""
|
"""
|
||||||
Truncate a message ID to a reasonable length for display.
|
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 message_id
|
||||||
return f"{message_id[:length]}..."
|
return f"{message_id[:length]}..."
|
||||||
|
|
||||||
|
|
||||||
def load_last_sync_timestamp():
|
def load_last_sync_timestamp():
|
||||||
"""
|
"""
|
||||||
Load the last synchronization timestamp from a file.
|
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.
|
float: The timestamp of the last synchronization, or 0 if not available.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with open('sync_timestamp.json', 'r') as f:
|
with open("sync_timestamp.json", "r") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
return data.get('timestamp', 0)
|
return data.get("timestamp", 0)
|
||||||
except (FileNotFoundError, json.JSONDecodeError):
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def save_sync_timestamp():
|
def save_sync_timestamp():
|
||||||
"""
|
"""
|
||||||
Save the current timestamp as the last synchronization timestamp.
|
Save the current timestamp as the last synchronization timestamp.
|
||||||
@@ -46,8 +50,9 @@ def save_sync_timestamp():
|
|||||||
None
|
None
|
||||||
"""
|
"""
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
with open('sync_timestamp.json', 'w') as f:
|
with open("sync_timestamp.json", "w") as f:
|
||||||
json.dump({'timestamp': current_time}, f)
|
json.dump({"timestamp": current_time}, f)
|
||||||
|
|
||||||
|
|
||||||
def format_datetime(dt_str, format_string="%m/%d %I:%M %p"):
|
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:
|
if not dt_str:
|
||||||
return ""
|
return ""
|
||||||
try:
|
try:
|
||||||
dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
|
dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
|
||||||
return dt.strftime(format_string)
|
return dt.strftime(format_string)
|
||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
return dt_str
|
return dt_str
|
||||||
|
|
||||||
|
|
||||||
def format_mime_date(dt_str):
|
def format_mime_date(dt_str):
|
||||||
"""
|
"""
|
||||||
Format a datetime string from ISO format to RFC 5322 format for MIME Date headers.
|
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:
|
if not dt_str:
|
||||||
return ""
|
return ""
|
||||||
try:
|
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)
|
return email.utils.format_datetime(dt)
|
||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
return dt_str
|
return dt_str
|
||||||
|
|
||||||
|
|
||||||
def safe_filename(filename):
|
def safe_filename(filename):
|
||||||
"""
|
"""
|
||||||
Convert a string to a safe filename.
|
Convert a string to a safe filename.
|
||||||
@@ -98,9 +105,10 @@ def safe_filename(filename):
|
|||||||
"""
|
"""
|
||||||
invalid_chars = '<>:"/\\|?*'
|
invalid_chars = '<>:"/\\|?*'
|
||||||
for char in invalid_chars:
|
for char in invalid_chars:
|
||||||
filename = filename.replace(char, '_')
|
filename = filename.replace(char, "_")
|
||||||
return filename
|
return filename
|
||||||
|
|
||||||
|
|
||||||
def ensure_directory_exists(directory):
|
def ensure_directory_exists(directory):
|
||||||
"""
|
"""
|
||||||
Ensure that a directory exists, creating it if necessary.
|
Ensure that a directory exists, creating it if necessary.
|
||||||
@@ -114,6 +122,7 @@ def ensure_directory_exists(directory):
|
|||||||
if not os.path.exists(directory):
|
if not os.path.exists(directory):
|
||||||
os.makedirs(directory)
|
os.makedirs(directory)
|
||||||
|
|
||||||
|
|
||||||
def parse_maildir_name(filename):
|
def parse_maildir_name(filename):
|
||||||
"""
|
"""
|
||||||
Parse a Maildir filename to extract components.
|
Parse a Maildir filename to extract components.
|
||||||
@@ -125,9 +134,104 @@ def parse_maildir_name(filename):
|
|||||||
tuple: (message_id, flags) components of the filename.
|
tuple: (message_id, flags) components of the filename.
|
||||||
"""
|
"""
|
||||||
# Maildir filename format: unique-id:flags
|
# Maildir filename format: unique-id:flags
|
||||||
if ':' in filename:
|
if ":" in filename:
|
||||||
message_id, flags = filename.split(':', 1)
|
message_id, flags = filename.split(":", 1)
|
||||||
else:
|
else:
|
||||||
message_id = filename
|
message_id = filename
|
||||||
flags = ''
|
flags = ""
|
||||||
return message_id, 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